fix(i18n): dịch nốt chữ do Qt tự vẽ, và thêm dòng phiên bản ở góc phải
CI / test (pull_request) Canceled after 0s

Người dùng báo: chọn tiếng Nhật mà nhóm Sandbox Security, nút Save/Cancel và
nhiều chỗ khác vẫn tiếng Anh. Bộ test i18n cũ vẫn xanh vì nó chỉ bắt lỗi "có
dịch nhưng không ai áp lại" — hai lỗ thật nằm chỗ khác:

* Chuỗi HARDCODE không đi qua ``tr()`` bao giờ (``QPushButton("Unlock")``), nên
  phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
  lần chụp và coi là bình thường.
* Nhãn nút do CHÍNH Qt vẽ. ``QDialogButtonBox``, ``QMessageBox.question`` và
  ``QInputDialog.get*`` lấy chữ từ bảng dịch riêng của Qt; ứng dụng không cài
  ``QTranslator`` nào và bản PySide6 đang dùng cũng không đóng gói file
  ``qtbase_*.qm`` nào để cài — nên chúng luôn rơi về tiếng Anh.

``ui/dialog_buttons.py`` gán nhãn của dự án đè lên nhãn Qt: ``dialog_buttons``
(10 hộp thoại), ``confirm`` (13 hộp Có/Không), ``ask_text``/``ask_multiline``/
``ask_item`` (16 hộp nhập liệu). Cùng với 17 chuỗi hardcode và 5 câu lỗi mà
``core/tasks.py`` trả thẳng ra hộp thoại — nay trả KHOÁ i18n, nơi hiển thị mới
gọi ``tr()`` — là 61 chỗ.

Ba chỗ nữa cùng lớp lỗi, phát hiện khi rà lại:

* ``_add_section`` nhận chuỗi ĐÃ dịch nên bốn tiêu đề mục của Step config đứng
  nguyên ở ngôn ngữ lúc dựng panel. Nay nhận khoá + ``bind_dynamic`` để không
  mất trạng thái gập/mở khi đổi ngôn ngữ.
* Thẻ tool ở Giám sát ▸ Công cụ hiện thẳng ``spec.description`` — chuỗi gửi cho
  MÔ HÌNH trong schema function-calling, phải giữ tiếng Anh. Thêm bộ mô tả hiển
  thị riêng cho 9 tool.
* Tên nhóm catalog ở tab Connector ("Other (any generic MCP server)").

Kèm theo, phần giao diện người dùng yêu cầu:

* ``__version__`` 2.26.0 -> 0.0.1, một nguồn cho tiêu đề cửa sổ, tab Giới thiệu
  và dòng mới ở góc phải thanh trạng thái (thay dòng ghi công tác giả).
* Tắt size grip: nó vẽ một vệt ngay bên phải dòng phiên bản. Cửa sổ vẫn kéo
  giãn được từ các cạnh.
* ``_NAV_SETTINGS_GAP`` 10 -> 4: hàng Cài đặt bớt xa nhóm Dashboard/Giám sát.

Hai test SẼ TREO nếu không sửa kèm: chúng patch ``QInputDialog.getText/getItem``
để tự trả lời, mà code nay gọi ``ask_text``/``ask_item`` — patch không còn chặn
được và hộp thoại thật sẽ mở ra chờ người bấm.

Ba cổng mới trong ``tests/ui/test_i18n_khong_hardcode_chu.py`` canh ở mức cấu
trúc (không ai được dựng lại kiểu cũ); đã kiểm chúng CẮN trên bản trước khi sửa:
10 + 13 + 17 vi phạm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 01:43:22 +09:00
committed by thanhnv
co-authored by Claude Opus 5
parent aeecdf5da0
commit d2101e1f87
52 changed files with 899 additions and 191 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ network control, permission management, audit log). No login required —
starts directly with full admin access.
"""
__version__ = "2.26.0"
__version__ = "0.0.1"
# Internal/technical name — config dir (~/.cowork_local), QSettings org keys,
# packaging scripts and docs still use this; do NOT rebrand it.
APP_NAME = "Cowork Local"
+25
View File
@@ -111,6 +111,28 @@ Ba luật:
chúng tự dựng `QLabel` bên trong, không giữ tham chiếu nào để áp lại. Truyền
`bind_text(QLabel(), key)` hoặc truyền **khoá** thay vì chuỗi đã dịch.
### 2.0b. Nút do CHÍNH Qt vẽ chữ — `ui/dialog_buttons.py`
`tr()` không với tới được nhãn nút của mấy widget dựng sẵn: Qt lấy chữ từ bảng dịch của
riêng nó, mà ứng dụng không cài `QTranslator` nào (bản PySide6 đang dùng cũng không đóng
gói file `qtbase_*.qm` nào để cài). Kết quả: **luôn là tiếng Anh ở cả ba ngôn ngữ.**
| Không dùng | Dùng thay |
| --- | --- |
| `QDialogButtonBox(Save \| Cancel)` | `dialog_buttons(Save \| Cancel)` |
| `QMessageBox.question(...) == QMessageBox.Yes` | `confirm(parent, title, body)` |
| `QInputDialog.getText / getMultiLineText / getItem` | `ask_text` / `ask_multiline` / `ask_item` |
Muốn một nút mang chữ riêng thì truyền khoá vào `dialog_buttons`, **không** `setText(tr(...))`
sau khi dựng — lần đổi ngôn ngữ kế tiếp, ràng buộc sẽ áp lại khoá mặc định và xoá mất chữ đó:
```python
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
ok="schedtask.ai_confirm")
```
Ba cổng trong `tests/ui/test_i18n_khong_hardcode_chu.py` canh việc này.
### 2.1. Widget sống lâu
Ví dụ:
@@ -338,6 +360,9 @@ Trước khi hoàn thành bản vá i18n, phải kiểm tra:
* [ ] Không còn chuỗi hardcode mới trong bản vá?
* [ ] Nút hộp thoại đi qua `ui/dialog_buttons.py` (mục 2.0b), không dựng
`QDialogButtonBox` / `QMessageBox.question` / `QInputDialog.get*` trực tiếp?
* [ ] Layout vẫn đúng với **chuỗi dài nhất** trong 3 ngôn ngữ?
* [ ] Không dùng `len()` để tính chiều rộng text?
+13 -8
View File
@@ -258,9 +258,13 @@ def dependencies_met(task: Dict[str, Any], directory: Path = None) -> bool:
def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
depends_on: List[str]) -> Optional[str]:
"""Validate a proposed depends_on list: no self-wait, no wait-cycle
(A waits B while B — directly or transitively — waits A)."""
(A waits B while B — directly or transitively — waits A).
Trả về KHOÁ i18n chứ không phải câu đã dịch: tầng này không biết người dùng
đang chọn ngôn ngữ nào, nên nơi hiển thị mới là nơi gọi ``tr()``.
"""
if task_id in (depends_on or []):
return "A task cannot wait for itself."
return "schedtask.err_self_wait"
by_id = {t["task_id"]: t for t in tasks}
# DFS from each proposed prerequisite through ITS prerequisites.
for start in depends_on or []:
@@ -268,7 +272,7 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
while stack:
cur = stack.pop()
if cur == task_id:
return "This would create a circular wait between tasks."
return "schedtask.err_wait_cycle"
if cur in seen:
continue
seen.add(cur)
@@ -280,22 +284,23 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
def chain_error(tasks: List[Dict[str, Any]], task_id: str,
next_task_id: Optional[str]) -> Optional[str]:
"""Validate assigning ``next_task_id`` as ``task_id``'s next task.
Returns an error string (self-link / circular chain / unknown id), or
None when the assignment is safe."""
Returns an i18n KEY for the problem (self-link / circular chain / unknown
id), or None when the assignment is safe. Khoá chứ không phải câu đã dịch —
xem ``depends_cycle_error``."""
if not next_task_id:
return None
if next_task_id == task_id:
return "A task cannot chain to itself."
return "schedtask.err_self_chain"
by_id = {t["task_id"]: t for t in tasks}
if next_task_id not in by_id:
return "Next task does not exist."
return "schedtask.err_next_missing"
# Walk forward from the proposed next task; reaching task_id again means
# the new edge would close a cycle.
seen = {task_id}
cur = next_task_id
while cur:
if cur in seen:
return "This would create a circular task chain."
return "schedtask.err_chain_cycle"
seen.add(cur)
cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id")
return None
+5 -1
View File
@@ -49,6 +49,8 @@ from . import libreoffice_view as _libreoffice_view
from . import agents_admin_tab as _agents_admin_tab
from . import monitoring_overview as _monitoring_overview
from . import cloud_workspace as _cloud_workspace
from . import dialog_buttons as _dialog_buttons
from . import connectors as _connectors
# Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả
# còn nằm chung một dict literal.
@@ -63,7 +65,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
**_libreoffice_view.STRINGS,
**_agents_admin_tab.STRINGS,
**_monitoring_overview.STRINGS,
**_cloud_workspace.STRINGS,
**_cloud_workspace.STRINGS,
**_dialog_buttons.STRINGS,
**_connectors.STRINGS,
}
+32
View File
@@ -0,0 +1,32 @@
"""Chuỗi hiển thị — nhóm Connector (Giám sát ▸ Công cụ ▸ Connector).
Tên bốn nhóm catalog trên bảng Connector. Đứng riêng một file vì
``libreoffice_view.py`` — nơi giữ các khoá ``connectors.*`` cũ — đã sát trần
400 dòng của Gate S; khoá connector thêm mới đi vào đây.
Ba nhóm CAD / CAE / MS365 là DANH SÁCH TÊN SẢN PHẨM nên giống hệt nhau ở cả ba
ngôn ngữ (đã khai vào ``KHOA_KHONG_CAN_DICH`` của test i18n). Chỉ nhóm "Other"
có chữ thật để dịch — đúng chỗ người dùng báo còn nguyên tiếng Anh.
``ui/connectors_panel.py`` tách nhãn tại chuỗi ``" ("`` để in phần trong ngoặc
bằng kiểu chữ phụ, nên bản dịch phải dùng ngoặc ĐƠN NỬA CHIỀU RỘNG kèm một dấu
cách phía trước — dùng ngoặc full-width ``(`` của tiếng Nhật thì không tách
được và cả cụm sẽ in đậm thành một khối.
"""
from __future__ import annotations
from typing import Dict
_CAD = "CAD (NX / CATIA / SolidWorks / AutoCAD)"
_CAE = "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)"
_MS365 = "MS365 (Microsoft 365 / OneDrive / SharePoint)"
STRINGS: Dict[str, Dict[str, str]] = {
"connectors.cat_cad": {"en": _CAD, "ja": _CAD, "vi": _CAD},
"connectors.cat_cae": {"en": _CAE, "ja": _CAE, "vi": _CAE},
"connectors.cat_ms365": {"en": _MS365, "ja": _MS365, "vi": _MS365},
"connectors.cat_other": {
"en": "Other (any generic MCP server)",
"ja": "その他 (任意の汎用 MCP サーバー)",
"vi": "Khác (MCP server bất kỳ)"},
}
+23
View File
@@ -0,0 +1,23 @@
"""Nhãn cho các nút CHUẨN của Qt (Save/Cancel/OK/Close, Yes/No).
Qt tự vẽ chữ cho những nút này từ bảng dịch của chính nó, mà ứng dụng không
cài ``QTranslator`` nào — nên chúng đứng nguyên tiếng Anh ở cả ba ngôn ngữ.
``ui/dialog_buttons.py`` gán lại nhãn bằng các khoá dưới đây.
Khoá dùng chung cho mọi hộp thoại nên đứng riêng một file, không nhét vào file
của một màn hình cụ thể.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"dialog.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"dialog.cancel": {"en": "Cancel", "ja": "キャンセル", "vi": "Hủy"},
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — kể cả bản tiếng Nhật của Qt cũng
# dùng "OK". Đã khai vào KHOA_KHONG_CAN_DICH của test i18n.
"dialog.ok": {"en": "OK", "ja": "OK", "vi": "OK"},
"dialog.close": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"dialog.yes": {"en": "Yes", "ja": "はい", "vi": "Có"},
"dialog.no": {"en": "No", "ja": "いいえ", "vi": "Không"},
}
+19
View File
@@ -339,4 +339,23 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "No usage recorded in this period yet — run a chat or a task first.",
"ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。",
"vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."},
# Lỗi hợp lệ hoá phụ thuộc/chuỗi task: ``core/tasks.py`` trả về KHOÁ, nơi
# hiển thị mới gọi ``tr()`` (tầng core không biết ngôn ngữ đang chọn).
"schedtask.err_self_wait": {
"en": "A task cannot wait for itself.", "ja": "タスクは自分自身を待てません。",
"vi": "Một task không thể chờ chính nó."},
"schedtask.err_wait_cycle": {
"en": "This would create a circular wait between tasks.",
"ja": "タスク間で待ち合わせが循環してしまいます。",
"vi": "Việc này sẽ tạo vòng chờ luẩn quẩn giữa các task."},
"schedtask.err_self_chain": {
"en": "A task cannot chain to itself.", "ja": "タスクは自分自身に連結できません。",
"vi": "Một task không thể nối tiếp chính nó."},
"schedtask.err_next_missing": {
"en": "Next task does not exist.", "ja": "次のタスクが存在しません。",
"vi": "Task kế tiếp không tồn tại."},
"schedtask.err_chain_cycle": {
"en": "This would create a circular task chain.",
"ja": "タスクの連結が循環してしまいます。",
"vi": "Việc này sẽ tạo chuỗi task luẩn quẩn."},
}
+45
View File
@@ -134,6 +134,45 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. "
"Connector MCP / REST-API được thiết lập ở tab con Connector."},
"tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
# Mô tả tool HIỂN THỊ trên thẻ, một khoá cho mỗi ``TOOL_SPECS[].name``.
# KHÔNG dùng ``spec.description``: chuỗi đó là mô tả gửi cho mô hình trong
# schema function-calling, phải giữ nguyên tiếng Anh và viết cho máy đọc.
"tools_admin.desc.read_file": {
"en": "Read the contents of a text file in the working folder.",
"ja": "作業フォルダー内のテキストファイルの内容を読み取ります。",
"vi": "Đọc nội dung một tệp văn bản trong thư mục làm việc."},
"tools_admin.desc.list_dir": {
"en": "List files and subfolders at a path (defaults to the workdir root).",
"ja": "指定パスのファイルとサブフォルダーを一覧表示します(既定は作業フォルダー直下)。",
"vi": "Liệt kê tệp và thư mục con tại một đường dẫn (mặc định là gốc thư mục làm việc)."},
"tools_admin.desc.write_file": {
"en": "Create a new file or fully rewrite one. For small edits, prefer edit_file.",
"ja": "ファイルを新規作成、または全体を書き換えます。小さな修正には edit_file を使います。",
"vi": "Tạo tệp mới hoặc ghi đè toàn bộ. Sửa nhỏ thì nên dùng edit_file."},
"tools_admin.desc.edit_file": {
"en": "Replace an exact snippet inside an existing file — preferred for small edits.",
"ja": "既存ファイル内の特定の箇所を置き換えます。小さな修正に適しています。",
"vi": "Thay chính xác một đoạn trong tệp có sẵn — hợp cho các sửa đổi nhỏ."},
"tools_admin.desc.run_command": {
"en": "Run a shell command in the working folder and return its output.",
"ja": "作業フォルダーでシェルコマンドを実行し、その出力を返します。",
"vi": "Chạy một lệnh shell trong thư mục làm việc và trả về kết quả."},
"tools_admin.desc.install_package": {
"en": "Install a Python package (pip) so the task can use a missing library.",
"ja": "不足しているライブラリを使えるよう Python パッケージ(pip)をインストールします。",
"vi": "Cài gói Python (pip) để tác vụ dùng được thư viện còn thiếu."},
"tools_admin.desc.fetch_url": {
"en": "Fetch a web page or online document by URL and return its text.",
"ja": "URL から Web ページやオンライン文書を取得し、テキストを返します。",
"vi": "Tải trang web hoặc tài liệu trực tuyến theo URL và trả về nội dung văn bản."},
"tools_admin.desc.jira_search": {
"en": "Search Jira issues with a JQL query and return a summary list. Read-only.",
"ja": "JQL クエリで Jira の課題を検索し、一覧を返します。読み取り専用です。",
"vi": "Tìm issue Jira bằng truy vấn JQL và trả về danh sách tóm tắt. Chỉ đọc."},
"tools_admin.desc.jira_get_issue": {
"en": "Read one Jira issue's details by key, e.g. ABX-123.",
"ja": "キー(例: ABX-123)を指定して Jira 課題の詳細を読み取ります。",
"vi": "Đọc chi tiết một issue Jira theo mã, ví dụ ABX-123."},
"tools_admin.url_fetch_group": {
"en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)",
"vi": "Truy cập web (fetch_url)"},
@@ -268,6 +307,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"},
"co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"},
"co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"},
"co4e.canvas_add_next": {
"en": "Add next step", "ja": "次のステップを追加", "vi": "Thêm bước kế"},
"co4e.canvas_connect_from": {
"en": "Connect from here", "ja": "ここから接続", "vi": "Nối từ đây"},
"co4e.canvas_delete_edge": {
"en": "Delete connection", "ja": "接続を削除", "vi": "Xóa liên kết"},
"co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"},
"co4e.fit_tooltip": {
"en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示",
+22
View File
@@ -45,6 +45,28 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải "
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."},
"settings.sandbox_pw_label": {
"en": "Sandbox Security Password", "ja": "サンドボックスセキュリティのパスワード",
"vi": "Mật khẩu Bảo mật Sandbox"},
"settings.sandbox_pw_placeholder": {
"en": "Enter password to edit sandbox settings",
"ja": "サンドボックス設定を変更するにはパスワードを入力してください",
"vi": "Nhập mật khẩu để sửa thiết lập sandbox"},
"settings.sandbox_unlock_btn": {"en": "Unlock", "ja": "ロック解除", "vi": "Mở khoá"},
"settings.sandbox_locked": {
"en": "Locked (changes disabled)", "ja": "ロック中(変更できません)",
"vi": "Đang khoá (không sửa được)"},
"settings.sandbox_unlocked": {
"en": "Unlocked", "ja": "ロック解除済み", "vi": "Đã mở khoá"},
"settings.sandbox_unlocked_body": {
"en": "Sandbox settings unlocked.", "ja": "サンドボックス設定のロックを解除しました。",
"vi": "Đã mở khoá thiết lập sandbox."},
"settings.sandbox_pw_wrong_title": {
"en": "Wrong Password", "ja": "パスワードが違います", "vi": "Sai mật khẩu"},
"settings.sandbox_pw_wrong_body": {
"en": "Password incorrect. Sandbox settings remain locked.",
"ja": "パスワードが正しくありません。サンドボックス設定はロックされたままです。",
"vi": "Mật khẩu không đúng. Thiết lập sandbox vẫn bị khoá."},
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
+13 -1
View File
@@ -79,7 +79,10 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.status.ready": {"en": "Ready.", "ja": "準備完了。", "vi": "Sẵn sàng."},
"app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."},
"app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."},
"app.credit": {"en": "Made by QuanDH14", "ja": "Made by QuanDH14", "vi": "Made by QuanDH14"},
# Con số lấy từ ``cowork_local.__version__`` — một nguồn duy nhất cho tiêu
# đề cửa sổ, tab Giới thiệu và góc dưới phải. Giữ nguyên dạng ở cả ba ngôn
# ngữ (đã khai vào KHOA_KHONG_CAN_DICH).
"app.version": {"en": "Version {v}", "ja": "Version {v}", "vi": "Version {v}"},
"app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"},
"app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"},
"app.tray.running_body": {
@@ -197,6 +200,15 @@ STRINGS: Dict[str, Dict[str, str]] = {
"chat.provider_default_short": {
"en": "the provider's default model", "ja": "プロバイダー既定のモデル",
"vi": "model mặc định của provider"},
"chat.provider_default_item": {
"en": "(provider default)", "ja": "(プロバイダー既定)",
"vi": "(mặc định của provider)"},
"chat.record_audio_start": {
"en": "Record Voice Note", "ja": "ボイスメモを録音", "vi": "Ghi âm ghi chú"},
"chat.record_audio_stop": {
"en": "Stop Recording", "ja": "録音を停止", "vi": "Dừng ghi âm"},
"chat.record_audio_cancel": {
"en": "Cancel recording", "ja": "録音をキャンセル", "vi": "Hủy ghi âm"},
"chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"chat.delete_tooltip": {
"en": "Delete this message and its input/output files",
+10 -5
View File
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from cowork_local.i18n import tr
from cowork_local.i18n import bind_dynamic, bind_tip, tr
from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon
@@ -57,7 +57,7 @@ class AudioRecorderWidget(QWidget):
# Record / Stop toggle button
self.record_btn = QPushButton()
self.record_btn.setIcon(icon("microphone"))
self.record_btn.setToolTip(tr("chat.record_audio_start") if tr("chat.record_audio_start") != "chat.record_audio_start" else "Record Voice Note")
bind_dynamic(self.record_btn, self._sync_record_tip)
self.record_btn.setFixedSize(32, 32)
self.record_btn.clicked.connect(self.toggle_recording)
layout.addWidget(self.record_btn)
@@ -78,7 +78,7 @@ class AudioRecorderWidget(QWidget):
self.cancel_btn = QPushButton()
self.cancel_btn.setIcon(icon("x"))
self.cancel_btn.setToolTip("Cancel recording")
bind_tip(self.cancel_btn, "chat.record_audio_cancel")
self.cancel_btn.setFixedSize(24, 24)
self.cancel_btn.clicked.connect(self.cancel_recording)
status_layout.addWidget(self.cancel_btn)
@@ -106,7 +106,7 @@ class AudioRecorderWidget(QWidget):
self.timer_label.setText("00:00")
self.status_container.setVisible(True)
self.record_btn.setIcon(icon("square"))
self.record_btn.setToolTip("Stop Recording")
self._sync_record_tip()
self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;")
self._timer.start()
self.recording_started.emit()
@@ -137,7 +137,12 @@ class AudioRecorderWidget(QWidget):
self.status_container.setVisible(False)
self.record_btn.setIcon(icon("microphone"))
self.record_btn.setStyleSheet("")
self.record_btn.setToolTip("Record Voice Note")
self._sync_record_tip()
def _sync_record_tip(self) -> None:
"""Tooltip nút ghi âm nói việc nó sẽ làm tiếp, theo trạng thái hiện tại."""
self.record_btn.setToolTip(tr("chat.record_audio_stop" if self._is_recording
else "chat.record_audio_start"))
def _on_tick(self) -> None:
"""Update recording duration display every second."""
+1 -1
View File
@@ -141,7 +141,7 @@ class ChatAgentsMixin:
if not items and self.agent_combo.count() == 0:
# No models found and none configured — placeholder with data=None so
# we fall back to the provider's default model (never a fake name).
self.agent_combo.addItem("(provider default)", None)
self.agent_combo.addItem(tr("chat.provider_default_item"), None)
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
if getattr(self, "_admin_agent", None) is not None else keep)
idx = self.agent_combo.findData(keep_data) if keep_data else -1
+2 -2
View File
@@ -11,9 +11,9 @@ from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMessageBox
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.dialog_buttons import confirm
class ChatSessionMixin:
@@ -308,7 +308,7 @@ class ChatSessionMixin:
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
else:
prompt = tr("chatpanel.delete_confirm_plain")
if QMessageBox.question(self, tr("chatpanel.delete_confirm_title"), prompt) != QMessageBox.Yes:
if not confirm(self, tr("chatpanel.delete_confirm_title"), prompt):
return
for bubble in turn.get("bubbles", []):
bubble.setParent(None)
+5 -4
View File
@@ -34,6 +34,7 @@ from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu
from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node
from ...i18n import tr
from ...theme import current_palette
from .canvas_geometry import _elide, _rounded_path
@@ -233,9 +234,9 @@ class _NodeItem(QGraphicsObject):
def contextMenuEvent(self, e):
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
menu = QMenu()
a_add = menu.addAction("+ Add next step")
a_conn = menu.addAction("→ Connect from here")
a_del = menu.addAction("🗑 Delete step")
a_add = menu.addAction("+ " + tr("co4e.canvas_add_next"))
a_conn = menu.addAction("→ " + tr("co4e.canvas_connect_from"))
a_del = menu.addAction("🗑 " + tr("co4e.delete_step"))
chosen = menu.exec(e.screenPos())
if chosen is a_add:
self.canvas.add_step_below(self.node.id)
@@ -342,7 +343,7 @@ class _EdgeItem(QGraphicsPathItem):
def contextMenuEvent(self, e):
"""Menu chuột phải trên đường nối: xoá liên kết."""
menu = QMenu()
act_del = menu.addAction("🗑 Delete connection")
act_del = menu.addAction("🗑 " + tr("co4e.canvas_delete_edge"))
if menu.exec(e.screenPos()) is act_del:
self.canvas.delete_edge(self.edge)
e.accept()
+4 -4
View File
@@ -13,7 +13,7 @@ import re
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem
from PySide6.QtWidgets import QMenu, QMessageBox, QTableWidget, QTableWidgetItem
from ...core import co4e
from ...i18n import tr
from ...theme import current_palette
@@ -337,9 +337,9 @@ class Co4ERunsMixin:
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QInputDialog
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
from ...ui.dialog_buttons import ask_text
new, ok = ask_text(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip()
if not ok or not new or new == h.name:
return
+4 -3
View File
@@ -8,10 +8,11 @@ from __future__ import annotations
import re
from typing import List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu
from PySide6.QtWidgets import QMenu
from ...core import co4e
from ...core.co4e import STEP_DONE, STEP_RUNNING
from ...i18n import tr
from ...ui.dialog_buttons import ask_text
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names
@@ -118,8 +119,8 @@ class Co4EWorkflowCrudMixin:
wf = co4e.get_workflow(ident)
if wf is None:
return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name, ok = ask_text(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name = (name or "").strip()
if not ok or not name:
return
@@ -24,20 +24,21 @@ thứ tự kế thừa không ảnh hưởng hành vi (khác trường hợp
``co4e_canvas_widget.py``, nơi thứ tự mixin-trước-Qt-base là bắt buộc vì có
override trùng tên).
Import trong từng method giữ nguyên y hệt bản gốc (kể cả các import cục bộ có
vẻ thừa như ``from PySide6.QtWidgets import QInputDialog`` lặp lại bên trong
``_add_subagent``/``_edit_subagent`` dù đã có ở top-level) — chỉ số cấp `..`
được nâng lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
``presentation/co4e/`` (cách gốc 3 cấp).
Import trong từng method giữ nguyên y hệt bản gốc — chỉ số cấp `..` được nâng
lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
``presentation/co4e/`` (cách gốc 3 cấp). Riêng các lời gọi ``QInputDialog``
đã chuyển sang ``ui.dialog_buttons``: hàm tĩnh của Qt tự dựng hộp thoại bên
trong nên nút "Cancel" của nó luôn là tiếng Anh.
"""
from __future__ import annotations
from typing import List
from PySide6.QtWidgets import QInputDialog, QListWidgetItem
from PySide6.QtWidgets import QListWidgetItem
from ...core.co4e import SubAgent
from ...i18n import tr
from ...ui.dialog_buttons import ask_item, ask_multiline, ask_text
class _StepConfigActionsMixin:
@@ -67,14 +68,12 @@ class _StepConfigActionsMixin:
"""Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó)."""
if self._step is None:
return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names()
if names:
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names, 0, True) # editable: can type a new one
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names, 0, True) # editable: can type a new one
else:
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
name, ok = ask_text(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
name = (name or "").strip()
if not ok or not name:
return
@@ -89,13 +88,11 @@ class _StepConfigActionsMixin:
row = self.sub_list.row(item)
if not (0 <= row < len(self._step.sub_agents)):
return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names()
cur = self._step.sub_agents[row].agent
start = names.index(cur) if cur in names else 0
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names or [cur], start, True)
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names or [cur], start, True)
name = (name or "").strip()
if ok and name:
self._step.sub_agents[row].agent = name
@@ -151,7 +148,7 @@ class _StepConfigActionsMixin:
role = self.role_edit.text().strip()
if not name and not role:
return
hint, ok = QInputDialog.getMultiLineText(
hint, ok = ask_multiline(
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
if not ok:
return
+4 -4
View File
@@ -79,7 +79,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# (skills/files/sub-agents). No tabs/accordion: every group's border
# and heading are what separate it from its neighbours, and all three
# are on screen (or one scroll away) at once.
form, _basic_card = _add_section(outer, tr("co4e.tab_basic"))
form, _basic_card = _add_section(outer, "co4e.tab_basic")
self.label_edit = QLineEdit()
self.label_edit.textChanged.connect(self._on_edit)
@@ -124,7 +124,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.context_edit.textChanged.connect(self._on_edit)
form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit)
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
form2, _model_card = _add_section(outer, "co4e.tab_model_perm")
model_row = QHBoxLayout()
self.model_combo = QComboBox()
@@ -163,7 +163,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
vrow = QWidget(); vrow.setLayout(verify_row)
form2.addRow("", vrow)
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files"))
form3, _skills_card = _add_section(outer, "co4e.tab_skills_files")
# Skills checklist (registry skills)
self.skills_list = QListWidget()
@@ -193,7 +193,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# Skills & Tệp, since it's really a distinct group, just one that
# only applies to parallel-variant steps. load_step() hides the whole
# card for a non-parallel step (see is_par below).
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents"))
form4, self._parallel_card = _add_section(outer, "co4e.f_subagents")
self.sub_list = QListWidget()
self.sub_list.setMaximumHeight(90)
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
+16 -4
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
from ...i18n import bind_dynamic, tr
from ...theme import current_palette
_SECTION_ANIM_MS = 180
@@ -64,7 +65,7 @@ class _SectionHeader(QLabel):
super().showEvent(event)
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
def _add_section(outer: QVBoxLayout, title_key: str) -> tuple[QFormLayout, QWidget]:
"""One group of fields, collapsed to just its heading by default and
independently expandable, so a long step config reads as a short list of
group names until you open the one you need. Deliberately bare — no card
@@ -74,7 +75,11 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
the group's rows to ``form``; ``card`` is the whole section (header +
body) — hide it to remove the group entirely (e.g. for a section that
only applies to some steps), rather than hiding individual rows inside
an always-visible header."""
an always-visible header.
Nhận KHOÁ dịch, không nhận chuỗi đã dịch: nhãn mục do hàm này tự dựng nên
nơi gọi không giữ tham chiếu nào để áp lại: truyền ``tr(...)`` vào đây thì
bốn tiêu đề đứng nguyên ở ngôn ngữ lúc dựng panel."""
p = current_palette()
card = QWidget()
card_lay = QVBoxLayout(card)
@@ -96,7 +101,6 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
# diacritics.
header.ensurePolished()
header.setFixedHeight(header.fontMetrics().height())
header.setText(f"▶ {title}")
card_lay.addWidget(header)
body = QWidget()
@@ -112,6 +116,14 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
is_open = False
def _sync_header() -> None:
"""Nhãn mục: dấu gập/mở hiện tại + tiêu đề theo ngôn ngữ đang chọn."""
header.setText(f"{'▼' if is_open else '▶'} {tr(title_key)}")
# Ràng buộc ĐỘNG chứ không bind cứng một chuỗi: nhãn này mang cả trạng thái
# gập/mở, nên bind cứng sẽ trả nó về ▶ mỗi lần người dùng đổi ngôn ngữ.
bind_dynamic(header, _sync_header)
def _on_finished() -> None:
"""Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều
trường không bị cắt cụt.
@@ -130,7 +142,7 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
"""Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng."""
nonlocal is_open
is_open = not is_open
header.setText(f"{'▼' if is_open else '▶'} {title}")
_sync_header()
anim.stop()
if is_open:
body.setVisible(True)
+3 -3
View File
@@ -116,9 +116,9 @@ class HabitsWidget(QWidget):
"""Apply an AI-suggested cost-saving strategy AFTER the user
approves: turn on auto-compress and compress earlier (lower
threshold) + compress content before sending it to the agent."""
from PySide6.QtWidgets import QMessageBox
if QMessageBox.question(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
from ...ui.dialog_buttons import confirm
if not confirm(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")):
return
cx = self.ctx.config.data.setdefault("context", {})
cx["auto_compact"] = True
+3 -3
View File
@@ -290,9 +290,9 @@ class AiEditPipeline:
self.pending = None
self._owner.show_confirm_row(False)
if p.get("image_gens"):
from PySide6.QtWidgets import QMessageBox
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
from ...ui.dialog_buttons import confirm
if not confirm(self._owner, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm_gen")):
self._owner.status_message.emit(tr("folder.ai_image_declined"))
return
self._generate_then_finalize(p)
@@ -276,10 +276,9 @@ class OfficeDocumentRenderer:
from cowork_local.core import pptx_edit
o = self._owner
if not skip_confirm and pptx_edit.image_change_requested(content):
from PySide6.QtWidgets import QMessageBox
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm"))
if ok != QMessageBox.Yes:
from cowork_local.ui.dialog_buttons import confirm
if not confirm(o, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm")):
o.status_message.emit(tr("folder.ai_image_declined"))
return False
pptx_edit.apply_text_to_pptx(o.current_file, content)
@@ -20,6 +20,7 @@ from ....core import admin_agents, preview_ai
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.dialog_buttons import dialog_buttons
from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
@@ -95,7 +96,7 @@ class AgentEditDialog(QDialog):
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
self.enabled_chk.setChecked(agent.enabled if agent else True)
form.addRow("", self.enabled_chk)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -26,7 +26,7 @@ from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
@@ -35,6 +35,7 @@ from ....core import admin_agents
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.dialog_buttons import confirm
from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
from .agent_edit_dialog import AgentEditDialog
@@ -189,9 +190,8 @@ class AgentsAdminTab(QWidget):
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
if QMessageBox.question(
self, tr("agents_admin.delete_title"),
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
if not confirm(self, tr("agents_admin.delete_title"),
tr("agents_admin.delete_confirm", name=agent.name)):
return
admin_agents.delete_agent(agent.agent_id, self._dir())
self.refresh()
@@ -139,11 +139,11 @@ class PricingPanel(QGroupBox):
def _add_pricing_row(self) -> None:
"""Thêm một dòng đơn giá trống để người dùng điền tay."""
from PySide6.QtWidgets import QInputDialog
from ....ui.dialog_buttons import ask_text
from ....core import model_pricing as mp
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt"))
name, ok = ask_text(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt"))
name = (name or "").strip()
if not ok or not name:
return
@@ -179,9 +179,12 @@ class ToolsAdminTab(QWidget):
hdr.addWidget(sw)
lay.addLayout(hdr)
desc = QLabel(spec.description)
# spec.description là mô tả gửi cho mô hình (schema function-calling),
# luôn tiếng Anh và viết cho máy đọc — thẻ này dùng bản dịch riêng.
desc_text = tr(f"tools_admin.desc.{spec.name}")
desc = QLabel(desc_text)
desc.setWordWrap(True)
desc.setToolTip(spec.description)
desc.setToolTip(desc_text)
desc.setObjectName("hint")
desc.setStyleSheet("border: none;")
lay.addWidget(desc)
@@ -32,6 +32,7 @@ from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
from cowork_local.state import AppContext
from cowork_local.ui.dialog_buttons import dialog_buttons
from cowork_local.ui.icons import icon
@@ -79,8 +80,8 @@ class AiTaskCreatorDialog(QDialog):
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
ok="schedtask.ai_confirm")
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
self.buttons.accepted.connect(self._confirm)
self.buttons.rejected.connect(self.reject)
@@ -38,6 +38,7 @@ from cowork_local.infrastructure.persistence.json.task_repository_impl import (
)
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
from cowork_local.theme import current_palette
from cowork_local.ui.dialog_buttons import confirm
from cowork_local.ui.osutil import open_path
# Priority shown as a plain text tag (no colored-emoji squares). Only the
@@ -317,9 +318,8 @@ class KanbanBoardWidget(QWidget):
elif chosen == next_act:
self._create_next_from_output(task)
elif chosen == del_act:
if QMessageBox.question(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", ""))
) == QMessageBox.Yes:
if confirm(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", ""))):
self._service.delete(tid)
self.refresh()
@@ -335,9 +335,8 @@ class KanbanBoardWidget(QWidget):
"""Confirm, then delete every task in ``selected``. Split out of
_bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question(
self, tr("schedtask.menu_delete"),
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
if not confirm(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_multi_confirm", n=len(selected))):
return False
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
self._service.bulk_delete(ids)
@@ -381,7 +380,7 @@ class KanbanBoardWidget(QWidget):
nxt["dependency"]["previous_task_id"] = task["task_id"]
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
self._repo.save(nxt)
task["dependency"]["next_task_id"] = nxt["task_id"]
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
)
from cowork_local.i18n import tr
from cowork_local.ui.dialog_buttons import dialog_buttons
from cowork_local.ui.osutil import open_path
@@ -53,7 +54,7 @@ class RunHistoryDialog(QDialog):
self.table.itemDoubleClicked.connect(self._open_artifact)
root.addWidget(self.table, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons = dialog_buttons(QDialogButtonBox.Close)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
+4 -4
View File
@@ -43,10 +43,10 @@ class AboutSettingsWidget(QWidget):
self.app_label.setFont(font)
layout.addWidget(self.app_label)
self.credit_label = QLabel(tr("app.credit"))
self.credit_label.setObjectName("faint")
self.credit_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addWidget(self.credit_label)
self.version_label = QLabel(tr("app.version", v=__version__))
self.version_label.setObjectName("faint")
self.version_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addWidget(self.version_label)
layout.addStretch(1)
+4 -4
View File
@@ -177,10 +177,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self.help_agent.status_message.connect(self.statusBar().showMessage)
self.statusBar().showMessage(tr("app.status.ready"))
# Dòng ghi công tác giả đã chuyển vào Cài đặt ▸ Giới thiệu
# (presentation/settings/about_widget.py). Nó từng là widget thường trực
# ở góc dưới phải: chiếm một góc màn hình trên MỌI màn hình, suốt cả
# phiên, cho một thông tin chỉ cần đọc một lần.
# Góc dưới phải: đúng một dòng phiên bản (cùng nguồn với tiêu đề cửa sổ).
# Tắt size grip — nó vẽ một vệt ngay bên phải chữ; cửa sổ vẫn kéo được cạnh.
self.statusBar().setSizeGripEnabled(False)
self.statusBar().addPermanentWidget(QLabel(tr("app.version", v=__version__)))
self._restore_sessions()
# Open on "All projects…" — literally the same call the nav rail's link
# of that name makes, so the rail highlight and the content can never
+3 -1
View File
@@ -19,7 +19,9 @@ _NAV_ROW_GAP = 6
# 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.
_NAV_SETTINGS_GAP = 10
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không
# rộng đến mức trông như hai khu tách rời.
_NAV_SETTINGS_GAP = 4
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
_NAV_MIN_WIDTH = 232
+3 -3
View File
@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
)
from ...i18n import tr
from ...ui.dialog_buttons import confirm
#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``.
_DEFAULT_PROJECT_NAME = "Project"
@@ -292,9 +293,8 @@ class ProjectEditingMixin:
project = load_project(pid) if pid else None
if project is None:
return
if QMessageBox.question(
self, tr("workspace.delete"),
tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes:
if not confirm(self, tr("workspace.delete"),
tr("workspace.delete_confirm", name=project.name)):
return
delete_project(pid)
self._current_id = ""
@@ -63,7 +63,7 @@ QUIRK DA DONG DINH (xem case tuong ung ben duoi):
duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False
— du liệu van con trong step, chi khong hien tren UI.
* ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG,
code chuyen sang ``QInputDialog.getText`` thay vi ``getItem`` (dong
code chuyen sang ``ask_text`` thay vi ``ask_item`` (dong
401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin
+ custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot
nhanh code song.
@@ -98,7 +98,7 @@ import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from pathlib import Path as _PP
from PySide6.QtWidgets import QApplication, QInputDialog, QFileDialog, QListWidgetItem
from PySide6.QtWidgets import QApplication, QFileDialog, QListWidgetItem
from PySide6.QtCore import Qt
from cowork_local.config import CONFIG_DIR
@@ -269,25 +269,31 @@ assert p.sub_list.count() == before_n, "_step None -> _add_subagent la no-op"
print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK")
p._step = pstep
orig_getItem = QInputDialog.getItem
orig_getText = QInputDialog.getText
# Panel goi ask_item/ask_text cua ui/dialog_buttons (khong con goi thang ham
# tinh QInputDialog.get*: ham tinh tu dung hop thoai ben trong nen nut "Cancel"
# cua no luon tieng Anh). Patch dung namespace cua mixin dang goi — patch vao
# QInputDialog nhu truoc se KHONG chan duoc gi va hop thoai that se mo ra treo.
from cowork_local.presentation.co4e import node_property_actions_mixin as _acts
orig_getItem = _acts.ask_item
orig_getText = _acts.ask_text
changed_n = {"n": 0}
p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1))
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Picked Agent", True))
_acts.ask_item = lambda *a, **k: ("Picked Agent", True)
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"]
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"]
assert changed_n["n"] == 1
print("CASE_ADD_SUBAGENT_PICKED_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("", True))
_acts.ask_item = lambda *a, **k: ("", True)
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them"
assert changed_n["n"] == 1, "khong them thi khong emit changed"
print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Should Not Add", False))
_acts.ask_item = lambda *a, **k: ("Should Not Add", False)
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them"
print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
@@ -303,12 +309,12 @@ print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
# StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong
# hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao.
StepConfigPanel._available_agent_names = staticmethod(lambda: [])
QInputDialog.getText = staticmethod(lambda *a, **k: ("Typed Agent", True))
_acts.ask_text = lambda *a, **k: ("Typed Agent", True)
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"]
del StepConfigPanel._available_agent_names
QInputDialog.getItem = orig_getItem
QInputDialog.getText = orig_getText
_acts.ask_item = orig_getItem
_acts.ask_text = orig_getText
print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK")
# ---- _edit_subagent (410-428) -----------------------------------------------
@@ -323,7 +329,7 @@ def _fake_get_item(*a, **k):
return ("Renamed", True)
QInputDialog.getItem = staticmethod(_fake_get_item)
_acts.ask_item = _fake_get_item
item0 = p.sub_list.item(0)
p._edit_subagent(item0)
assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0"
@@ -335,7 +341,7 @@ foreign_item = QListWidgetItem("not in list")
snapshot = dict(captured)
p._edit_subagent(foreign_item)
assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op"
QInputDialog.getItem = orig_getItem
_acts.ask_item = orig_getItem
print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK")
# ---- _del_subagent (430-437) -------------------------------------------------
+6 -3
View File
@@ -35,11 +35,14 @@ def test_construction_and_retranslate(qapp, tmp_path) -> None:
def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
from PySide6.QtWidgets import QInputDialog
ctx = _FakeCtx(tmp_path)
panel = PricingPanel(ctx, on_status_message=lambda _m: None)
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True)))
# Panel hỏi tên qua ``ui.dialog_buttons.ask_text`` chứ không gọi thẳng hàm
# tĩnh ``QInputDialog.getText`` (hàm tĩnh tự dựng hộp thoại bên trong nên
# nút Cancel của nó luôn tiếng Anh). Patch nhầm chỗ thì hộp thoại THẬT mở
# ra và test treo cho tới khi hết giờ.
monkeypatch.setattr("cowork_local.ui.dialog_buttons.ask_text",
lambda *a, **k: ("gpt-test", True))
panel._add_pricing_row()
assert panel.table.rowCount() == 1
assert panel.table.item(0, 0).text() == "gpt-test"
+25 -16
View File
@@ -1,11 +1,12 @@
"""Mục "Giới thiệu" trong Cài đặt, và việc gỡ dòng ghi công khỏi thanh trạng thái.
"""Mục "Giới thiệu" trong Cài đặt, và dòng phiên bản ở góc dưới phải.
Dòng "Made by QuanDH14" từng là widget thường trực ở góc dưới phải: chiếm một góc
màn hình trên MỌI màn hình, suốt cả phiên, cho một thông tin chỉ cần đọc một lần.
Nó chuyển vào Cài đặt ▸ Giới thiệu — vẫn tra được, không còn đứng thường trực.
Dòng "Made by QuanDH14" từng là widget thường trực ở góc dưới phải, rồi chuyển
hẳn vào Cài đặt ▸ Giới thiệu. Nay chỗ đó mang MỘT thông tin khác: số phiên bản —
người dùng muốn tra được ngay mà không phải mở Cài đặt.
Hai đầu của việc chuyển này phải được chốt cùng nhau: chỉ thêm mục mới mà quên gỡ
chỗ cũ thì thành hiện hai lần; chỉ gỡ mà quên thêm thì mất hẳn.
Điểm phải chốt là **một nguồn duy nhất**: tiêu đề cửa sổ, tab Giới thiệu và góc
dưới phải đều đọc ``cowork_local.__version__``. Ghi cứng con số ở một trong ba
chỗ thì lần nâng phiên bản sau sẽ để lại một chỗ nói sai.
"""
from __future__ import annotations
@@ -41,11 +42,13 @@ def test_muc_gioi_thieu_dung_cuoi(dialog):
assert last == tr("settings.group.about")
def test_muc_gioi_thieu_hien_dong_ghi_cong(dialog):
def test_muc_gioi_thieu_hien_dong_phien_ban(dialog):
from cowork_local import __version__
from cowork_local.i18n import tr
assert dialog._about_page.credit_label.text() == tr("app.credit")
assert "QuanDH14" in dialog._about_page.credit_label.text()
text = dialog._about_page.version_label.text()
assert text == tr("app.version", v=__version__)
assert __version__ in text
def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog):
@@ -56,10 +59,10 @@ def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog):
assert __version__ in text
# ---- đầu CŨ: không còn ở thanh trạng thái --------------------------------
# ---- góc dưới phải: đúng một dòng, và là dòng phiên bản ------------------
def test_khong_con_dong_ghi_cong_trong_ma_nguon_cua_so_chinh():
"""Chốt cả hai đầu: cửa sổ chính không được dựng lại widget ghi công.
def test_goc_duoi_phai_hien_phien_ban_tu_cung_mot_nguon():
"""Thanh trạng thái mang ĐÚNG một widget thường trực, là dòng phiên bản.
Kiểm trên mã nguồn thay vì dựng ``MainWindow`` — dựng cửa sổ thật mất ~35
giây cho một khẳng định mà đọc file trả lời được ngay.
@@ -69,7 +72,13 @@ def test_khong_con_dong_ghi_cong_trong_ma_nguon_cua_so_chinh():
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "shell" / "main_window.py").read_text(encoding="utf-8")
assert "_credit" not in src, "vẫn còn widget ghi công ở cửa sổ chính"
assert "addPermanentWidget" not in src, (
"thanh trạng thái lại có widget thường trực — nếu là thứ khác thì sửa "
"khẳng định này, nhưng phải cân nhắc: nó chiếm một góc trên mọi màn hình")
assert "_credit" not in src, "widget ghi công tác giả đã chuyển vào Cài đặt"
assert src.count("addPermanentWidget") == 1, (
"góc dưới phải chỉ được mang MỘT widget thường trực — nó chiếm một góc "
"màn hình trên MỌI màn hình, suốt cả phiên làm việc")
assert 'tr("app.version", v=__version__)' in src, (
"dòng phiên bản phải đọc __version__ như tiêu đề cửa sổ và tab Giới "
"thiệu; ghi cứng con số ở đây thì lần nâng phiên bản sau sẽ nói sai")
assert "setSizeGripEnabled(False)" in src, (
"size grip vẽ một vệt ngay bên phải dòng phiên bản — góc đó chỉ được "
"mang mỗi con số; cửa sổ vẫn kéo giãn được từ các cạnh")
+5 -1
View File
@@ -34,7 +34,7 @@ MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào
#: vào đây phải kèm lý do — đây là chỗ dễ dùng để lặng lẽ bỏ qua việc dịch.
KHOA_KHONG_CAN_DICH = {
# thương hiệu / tên sản phẩm
"app.logo", "app.credit", "login.header",
"app.logo", "app.version", "login.header",
"app.tab.code", "app.tab.cowork", "app.tab.structure",
"code.title", "code.onedrive_badge", "code.onedrive_btn",
"cowork.title", "sidebar.filter.code", "sidebar.filter.cowork",
@@ -44,11 +44,15 @@ KHOA_KHONG_CAN_DICH = {
"settings.group.teams", "settings.history_onedrive",
"settings.ms365_connector.onedrive", "settings.ms365_connector.outlook",
"settings.ms365_connector.sharepoint", "settings.ms365_connector.teams",
# tên nhóm catalog = danh sách tên sản phẩm, giống hệt ở cả ba ngôn ngữ
"connectors.cat_cad", "connectors.cat_cae", "connectors.cat_ms365",
"schedtask.stepexec.co4e", "schedtask.stepexec.cowork",
"schedtask.type.co4e_code", "schedtask.type.cowork",
"monitoring.tab_mcp", "ext.mode_rest", "schedtask.f_cron",
"help_agent.badge", "help_agent.title", # dùng nguyên dạng trong cả câu tiếng Nhật
"help_agent.default_user", # đứng thay cho TÊN người dùng
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — bản tiếng Nhật của chính Qt cũng dùng "OK"
"dialog.ok",
# viết tắt / ký hiệu / thuần định dạng
"accounts.ai_search_btn", "monitoring.ai_filter_btn", "monitoring.overview_res_cpu",
"monitoring.na", "schedtask.add_link_label", "settings.teams_webhook",
+354
View File
@@ -0,0 +1,354 @@
"""Chữ trên giao diện phải đi qua ``tr()`` — kể cả chữ do CHÍNH Qt vẽ.
Bộ ``test_i18n_khong_con_chu_cu.py`` bắt lỗi "có dịch nhưng không ai áp lại".
Nó không bắt được lỗi người dùng báo tháng 9/2026: "đang tiếng Nhật mà nút Save,
Cancel và phần mật khẩu sandbox vẫn tiếng Anh". Lý do là hai lỗ khác hẳn:
* **Chuỗi hardcode.** ``QPushButton("Unlock")`` không hỏi ``tr()`` bao giờ, nên
phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
lần chụp và coi là bình thường.
* **Nhãn nút của chính Qt.** ``QDialogButtonBox(Save | Cancel)`` và
``QMessageBox.question`` lấy chữ từ bảng dịch của Qt. Ứng dụng không cài
``QTranslator`` nào nên bảng đó luôn rơi về tiếng Anh — không dòng mã nào của
dự án sai, mà màn hình vẫn sai.
Ba cổng dưới đây canh cả hai lỗ, và canh ở mức CẤU TRÚC (không ai được dựng lại
kiểu cũ) chứ không chỉ sửa xong từng chỗ.
"""
from __future__ import annotations
import ast
import pathlib
import pytest
REPO = pathlib.Path(__file__).resolve().parents[2]
#: Chỉ hai tầng này vẽ giao diện.
THU_MUC_UI = ("ui", "presentation")
#: Nơi DUY NHẤT được phép chạm vào nút chuẩn của Qt — chính chỗ gán nhãn lại.
NOI_BOC_QT = "ui/dialog_buttons.py"
def _file_ui():
"""Mọi file .py thuộc tầng giao diện, kèm cây AST đã phân tích."""
for thu_muc in THU_MUC_UI:
for path in sorted((REPO / thu_muc).rglob("*.py")):
rel = path.relative_to(REPO).as_posix()
yield rel, ast.parse(path.read_text(encoding="utf-8"))
def _ten_ham(func) -> str:
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return ""
# ---- cấu trúc: không ai được dùng lại nhãn mặc định của Qt ----------------
def test_khong_dung_nut_chuan_qdialogbuttonbox_truc_tiep():
"""``QDialogButtonBox(Save | Cancel)`` phải đi qua ``dialog_buttons()``.
Dựng thẳng thì Qt tự vẽ chữ, và chữ đó là tiếng Anh ở cả ba ngôn ngữ.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "QDialogButtonBox"
and node.args):
# Không tham số = hộp rỗng rồi tự addButton(tr(...)) — vẫn đúng.
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"dựng QDialogButtonBox với nút chuẩn thì nhãn do Qt vẽ và luôn là tiếng "
"Anh — dùng ui.dialog_buttons.dialog_buttons() thay vào:\n "
+ "\n ".join(vi_pham))
def test_khong_goi_qinputdialog_tinh_truc_tiep():
"""Hộp nhập liệu phải đi qua ``ask_text`` / ``ask_multiline`` / ``ask_item``.
``QInputDialog.getText`` và họ hàng tự dựng hộp thoại bên trong nên không ai
chạm được vào nút của nó: "Cancel" đứng nguyên tiếng Anh ở cả ja lẫn vi.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QInputDialog"
and node.func.attr.startswith("get")):
vi_pham.append(f"{rel}:{node.lineno} QInputDialog.{node.func.attr}")
assert vi_pham == [], (
"hàm tĩnh QInputDialog hiện nút Cancel tiếng Anh ở mọi ngôn ngữ — dùng "
"ask_text / ask_multiline / ask_item của ui.dialog_buttons:\n "
+ "\n ".join(vi_pham))
def test_khong_goi_qmessagebox_question_truc_tiep():
"""Hộp hỏi Có/Không phải đi qua ``confirm()``.
``QMessageBox.question`` hiện "&Yes" / "&No" tiếng Anh ở mọi ngôn ngữ.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == "question"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QMessageBox"):
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"QMessageBox.question hiện nút Yes/No tiếng Anh ở mọi ngôn ngữ — dùng "
"ui.dialog_buttons.confirm() thay vào:\n " + "\n ".join(vi_pham))
# ---- dữ liệu: không chuỗi hiển thị nào được viết thẳng vào widget ----------
#: Đối số thứ N của các hàm dựng widget này là NHÃN hiển thị.
CTOR_NHAN = {
"QLabel": 0, "QPushButton": 0, "QGroupBox": 0, "QCheckBox": 0,
"QRadioButton": 0, "QToolButton": 0, "QAction": 0, "QMenu": 0,
"QListWidgetItem": 0, "QTreeWidgetItem": 0, "QTableWidgetItem": 0,
"IconLabel": 1, "ToggleSwitch": 0,
}
#: Đối số thứ N của các setter này là chuỗi hiển thị.
SETTER_NHAN = {
"setText": 0, "setToolTip": 0, "setPlaceholderText": 0, "setWindowTitle": 0,
"setTitle": 0, "setStatusTip": 0, "setWhatsThis": 0, "setLabelText": 0,
"setInformativeText": 0, "setAccessibleName": 0, "addItem": 0,
"insertItem": 1, "setItemText": 1, "addAction": 0, "addTab": 1,
"insertTab": 2, "setTabText": 1, "setTabToolTip": 1, "setHeaderLabel": 0,
}
#: Mọi đối số chuỗi đều là nhãn.
SETTER_NHAN_TAT_CA = {
"setHorizontalHeaderLabels", "setHeaderLabels", "setVerticalHeaderLabels",
}
#: ``QMessageBox.<loai>(parent, title, body)`` — đối số 1 và 2 là chữ hiển thị.
MSGBOX_LOAI = {"information", "warning", "critical", "about"}
#: Chuỗi cố ý viết thẳng, kèm lý do. Thêm mục ở đây là một quyết định, không
#: phải cách làm cổng xanh lại — chữ người dùng ĐỌC ĐƯỢC thì phải dịch.
CHO_PHEP_HARDCODE = {
# ký hiệu đơn vị và tiền tố phiên bản: giống nhau ở cả ba ngôn ngữ
("presentation/monitoring/tabs/overview_tab.py", "/s"),
("presentation/settings/about_widget.py", " v"),
("presentation/settings/routing_settings_widget.py", " s"),
("presentation/settings/routing_settings_widget.py", " h"),
("presentation/shell/main_window.py", " v"),
("ui/task_editor_dialog.py", " s"),
}
def _chuoi(node) -> str | None:
"""Chữ của một literal chuỗi. Bỏ qua f-string đã có ``tr()`` bên trong."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
if any(isinstance(n, ast.Call) and _ten_ham(n.func) == "tr"
for n in ast.walk(node)):
return None
phan = [v.value for v in node.values
if isinstance(v, ast.Constant) and isinstance(v.value, str)]
return "".join(phan) if phan else None
return None
def _la_chu_nguoi_doc(s: str) -> bool:
"""Chuỗi có phải chữ người dùng ĐỌC, chứ không phải mã/ký hiệu/định dạng.
Mốc "ít nhất ba chữ cái" loại được đơn vị (``/s``, `` h``) và tiền tố phiên
bản (`` v``) mà vẫn giữ lại mọi nhãn thật — nhãn ngắn nhất trên màn này là
"Unlock".
"""
import re
if len(s.strip()) < 2 or re.fullmatch(r"[\W\d_]+", s):
return False
if re.fullmatch(r"[a-z][a-zA-Z0-9]*|[a-z0-9_.]+", s):
return False # objectName / khoá dạng định danh
if s.startswith(("http://", "https://", "#", "<")):
return False
return len(re.findall(r"[A-Za-zÀ-ỹ぀-ヿ一-鿿]", s)) >= 3
def test_khong_con_chuoi_hien_thi_viet_thang_vao_widget():
"""Không nhãn/tooltip/placeholder nào được viết thẳng bằng một ngôn ngữ.
Đây là nửa còn lại của lỗi tháng 9/2026: nhóm mật khẩu Sandbox Security
dựng bằng ``QPushButton("Unlock")`` và ``IconLabel(..., "Locked ...")``, nên
đổi sang tiếng Nhật thì cả cụm vẫn nguyên tiếng Anh.
"""
vi_pham = []
for rel, tree in _file_ui():
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn, args = _ten_ham(node.func), node.args
def bao(i: int) -> None:
if i >= len(args):
return
s = _chuoi(args[i])
if s and _la_chu_nguoi_doc(s) and (rel, s) not in CHO_PHEP_HARDCODE:
vi_pham.append(f"{rel}:{args[i].lineno} {fn}({s!r})")
if fn in CTOR_NHAN:
bao(CTOR_NHAN[fn])
if fn in SETTER_NHAN:
bao(SETTER_NHAN[fn])
if fn in SETTER_NHAN_TAT_CA:
for i in range(len(args)):
bao(i)
if (fn in MSGBOX_LOAI and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QMessageBox"):
bao(1)
bao(2)
assert vi_pham == [], (
"chữ hiển thị viết thẳng, không đi qua tr() nên không đổi theo ngôn "
"ngữ:\n " + "\n ".join(sorted(vi_pham)))
def test_moi_tool_co_mo_ta_hien_thi_rieng():
"""Mỗi tool trong ``TOOL_SPECS`` phải có khoá mô tả để hiển thị.
Thẻ tool ở Giám sát ▸ Công cụ từng hiện thẳng ``spec.description`` — chuỗi
gửi cho MÔ HÌNH trong schema function-calling, nên luôn tiếng Anh. Thiếu
khoá thì ``tr()`` trả về chính khoá, và người dùng đọc được
``tools_admin.desc.<tên>`` ngay trên thẻ.
"""
from cowork_local.core.tools import TOOL_SPECS
from cowork_local.i18n import STRINGS
thieu = [s.name for s in TOOL_SPECS
if f"tools_admin.desc.{s.name}" not in STRINGS]
assert thieu == [], (
"tool chưa có khoá mô tả hiển thị (thêm tools_admin.desc.<tên> vào "
f"i18n/libreoffice_view.py): {thieu}")
def test_add_section_nhan_khoa_chu_khong_nhan_chuoi_da_dich():
"""``_add_section`` tự dựng nhãn mục, nên phải nhận KHOÁ.
Truyền ``tr(...)`` vào đây hỏng ÂM THẦM: khoá thiếu thì ``tr()`` trả về
chính chuỗi đưa vào, nên tiêu đề hiện đúng ở ngôn ngữ lúc dựng panel rồi
đứng nguyên mãi. Đúng lỗi người dùng báo: mở Co4E ▸ Step config ở tiếng
Nhật thì đổi sang tiếng Anh vẫn còn tiếng Nhật.
"""
vi_pham = []
for rel, tree in _file_ui():
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "_add_section"
and len(node.args) >= 2
and isinstance(node.args[1], ast.Call)
and _ten_ham(node.args[1].func) == "tr"):
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"_add_section nhận khoá dịch, không nhận tr(...) — truyền chuỗi đã dịch "
"thì tiêu đề mục không bao giờ đổi lại:\n " + "\n ".join(vi_pham))
# ---- lúc chạy: nhãn nút thật sự đổi theo ngôn ngữ -------------------------
def test_tieu_de_muc_step_config_doi_theo_ngon_ngu(qapp):
"""Bốn tiêu đề mục của Step config phải đổi theo ngôn ngữ.
Panel này dựng LƯỜI (chỉ khi người dùng chọn một bước trên canvas Co4E) nên
nó không nằm trong cây widget mà ``test_i18n_khong_con_chu_cu`` quét — phải
dựng thẳng ở đây thì mới canh được.
"""
pytest.importorskip("PySide6")
from cowork_local import i18n
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
cu = i18n.get_language()
try:
i18n.set_language("ja")
panel = StepConfigPanel() # dựng TRONG lúc đang tiếng Nhật
nhat = [h.text() for h in panel.findChildren(_SectionHeader)]
assert nhat, "không tìm thấy tiêu đề mục nào"
i18n.set_language("en")
anh = [h.text() for h in panel.findChildren(_SectionHeader)]
assert all(a != n for a, n in zip(anh, nhat)), (
f"tiêu đề mục giữ nguyên ngôn ngữ lúc dựng panel: {anh}")
assert anh == [f"▶ {i18n.tr(k)}" for k in
("co4e.tab_basic", "co4e.tab_model_perm",
"co4e.tab_skills_files", "co4e.f_subagents")]
finally:
i18n.set_language(cu)
def test_doi_ngon_ngu_khong_lam_mat_trang_thai_gap_mo(qapp):
"""Mục đang MỞ vẫn phải mang dấu ▼ sau khi đổi ngôn ngữ.
Đây là lý do nhãn này dùng ``bind_dynamic`` chứ không ``bind_text``: bind
cứng một khoá sẽ ghi đè cả dấu gập/mở, trả mục đang mở về ▶ trong khi phần
thân của nó vẫn đang hiện.
"""
pytest.importorskip("PySide6")
from cowork_local import i18n
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
cu = i18n.get_language()
try:
i18n.set_language("en")
panel = StepConfigPanel()
header = panel.findChildren(_SectionHeader)[0]
header.clicked.emit() # mở mục đầu tiên
assert header.text().startswith("▼")
i18n.set_language("ja")
assert header.text().startswith("▼"), (
f"đổi ngôn ngữ làm mất trạng thái đang mở: {header.text()!r}")
finally:
i18n.set_language(cu)
def test_nhan_nut_hop_thoai_doi_theo_ngon_ngu(qapp):
"""Nút Save/Cancel mang chữ của dự án ở cả ba ngôn ngữ.
Chốt luôn cái bẫy vòng đời: ràng buộc giữ widget bằng ``weakref``, mà nút
chuẩn do C++ dựng nên wrapper Python của nó có thể bị thu hồi. Hộp thoại ở
đây thả hết tham chiếu cục bộ, đúng như mọi call site thật.
"""
pytest.importorskip("PySide6")
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from cowork_local import i18n
from cowork_local.ui.dialog_buttons import dialog_buttons
dlg = QDialog()
QVBoxLayout(dlg).addWidget(
dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel))
hop = dlg.findChild(QDialogButtonBox)
cu = i18n.get_language()
try:
for lang in ("en", "ja", "vi"):
i18n.set_language(lang)
mong_doi = [i18n.STRINGS["dialog.save"][lang],
i18n.STRINGS["dialog.cancel"][lang]]
assert [b.text() for b in hop.buttons()] == mong_doi, (
f"nhãn nút không theo ngôn ngữ {lang}")
finally:
i18n.set_language(cu)
dlg.close()
+4
View File
@@ -24,6 +24,10 @@ QSplitter::handle:horizontal { width: 1px; }
QSplitter::handle:vertical { height: 1px; }
QSplitter::handle:hover { background: $border_strong; }
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
/* Dòng phiên bản ở góc phải: đẩy chữ ra khỏi mép cửa sổ. Đặt padding ở nhãn
chứ không ở QStatusBar — margin/padding của thanh không dịch được widget mà
layout nội bộ của nó ghim vào sát mép phải. */
QStatusBar QLabel { padding-right: 10px; }
QToolTip {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 5px 9px;
+6 -6
View File
@@ -18,7 +18,7 @@ from typing import Dict, List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QMessageBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox,
QPushButton, QSplitter, QTableWidget, QTableWidgetItem, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget,
)
@@ -28,6 +28,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .dialog_buttons import ask_text, confirm, dialog_buttons
from .icons import DOT_AMBER, icon
from .widgets import fmt_tokens
@@ -151,7 +152,7 @@ class AccountEditDialog(QDialog):
self.group_combo.setCurrentIndex(idx)
self.group_combo.setEnabled(allow_group_edit)
form.addRow(tr("accounts.f_group"), self.group_combo)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -529,9 +530,8 @@ class AccountsTab(QWidget):
return
if not self._is_admin() and account.role != "user":
return # Sub-admin can only remove ordinary members, never another sub-admin
if QMessageBox.question(
self, tr("accounts.delete_title"),
tr("accounts.delete_confirm", username=account.username)) != QMessageBox.Yes:
if not confirm(self, tr("accounts.delete_title"),
tr("accounts.delete_confirm", username=account.username)):
return
accounts.delete_account(account.username, self._accounts_dir())
if account.group_id:
@@ -703,7 +703,7 @@ class AccountsTab(QWidget):
"""Hỏi tên rồi tạo một nhóm mới. Chỉ Admin."""
if not self._is_admin():
return
name, ok = QInputDialog.getText(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
name, ok = ask_text(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
if not (ok and name.strip()):
return
group = groups.new_group(name.strip())
+4 -5
View File
@@ -12,7 +12,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea,
QListWidgetItem, QPlainTextEdit, QPushButton, QScrollArea,
QSplitter, QVBoxLayout, QWidget,
)
@@ -22,6 +22,7 @@ from ..core.custom_agents import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import confirm
from .icons import icon
@@ -300,10 +301,8 @@ class AgentManagerTab(QWidget):
agent = self._current_agent()
if agent is None:
return
if QMessageBox.question(
self, tr("agentmgr.delete_btn"),
tr("agentmgr.delete_confirm", name=agent.name),
) != QMessageBox.Yes:
if not confirm(self, tr("agentmgr.delete_btn"),
tr("agentmgr.delete_confirm", name=agent.name)):
return
delete_agent(agent.name)
self._reload_list()
+4 -3
View File
@@ -9,7 +9,7 @@ from typing import List
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QInputDialog,
QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
from ..core.co4e import PERMISSION_PRESETS, CustomAgent
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline, dialog_buttons
from .icons import icon, icon_picker_combo
@@ -114,7 +115,7 @@ class Co4EAgentDialog(QDialog):
form.addRow(tr("co4e.f_attachments"), self.attach_list)
form.addRow("", abtn)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -129,7 +130,7 @@ class Co4EAgentDialog(QDialog):
role = self.role_edit.text().strip()
if not name and not role:
return
hint, ok = QInputDialog.getMultiLineText(
hint, ok = ask_multiline(
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
if not ok:
return
+11 -9
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
@@ -21,6 +21,7 @@ from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
from ..core.worker import AgentWorker
from ..i18n import bind_text, on_language_changed, tr
from ..state import AppContext
from .dialog_buttons import confirm
from .ext_connector_dialog import ExtConnectorEditDialog
from .icons import icon
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
@@ -129,11 +130,13 @@ class ConnectorsPanel(QWidget):
Có một công tắc tổng: tắt là agent không nối ra connector ngoài nào cả.
"""
# Khoá dịch, không phải chữ sẵn: nhãn nhóm dựng lại trong _reload_connectors
# nên tr() ở đó là đủ (xem i18n/connectors.py).
_EXT_CATEGORY_LABELS = {
"cad": "CAD (NX / CATIA / SolidWorks / AutoCAD)",
"cae": "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)",
"ms365": "MS365 (Microsoft 365 / OneDrive / SharePoint)",
"other": "Other (any generic MCP server)",
"cad": "connectors.cat_cad",
"cae": "connectors.cat_cae",
"ms365": "connectors.cat_ms365",
"other": "connectors.cat_other",
}
_EXT_CATEGORY_ICONS = {"cad": "wrench", "cae": "ruler", "ms365": "cloud", "other": "plug"}
# TEMPORARY: only OneDrive + SharePoint (auto-connect via locally-synced
@@ -232,7 +235,7 @@ class ConnectorsPanel(QWidget):
icon_lbl = QLabel()
icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18))
hdr.addWidget(icon_lbl)
name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (")
name, _, subtitle = tr(self._EXT_CATEGORY_LABELS.get(cat, cat)).partition(" (")
name_lbl = QLabel(name)
name_lbl.setStyleSheet("font-weight:700;")
hdr.addWidget(name_lbl)
@@ -373,9 +376,8 @@ class ConnectorsPanel(QWidget):
def _delete_ext_entry(self, cat: str, entry: dict) -> None:
"""Xoá một connector MCP sau khi hỏi xác nhận."""
if QMessageBox.question(
self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
if not confirm(self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))):
return
self.ctx.config.ext_connectors[cat].remove(entry)
self.ctx.save()
+110
View File
@@ -0,0 +1,110 @@
"""Nút hộp thoại mang chữ của ứng dụng, không phải chữ mặc định của Qt.
``QDialogButtonBox(Save | Cancel)``, ``QMessageBox.question`` và
``QInputDialog.get*`` lấy nhãn nút từ bảng dịch của CHÍNH Qt. Ứng dụng không cài
``QTranslator`` nào — và bản PySide6 đang dùng cũng không đóng gói sẵn file
``qtbase_*.qm`` nào để cài — nên bảng đó luôn rơi về tiếng Anh: chọn tiếng Nhật
thì hộp thoại vẫn hiện "Save" / "Cancel" / "&Yes" / "&No". Đây là lỗi người dùng
báo, và nó không nằm ở dây nối i18n của dự án — ``tr()`` chưa từng được hỏi tới.
Các hàm dưới đây gán nhãn của dự án đè lên nhãn Qt. Dùng chúng thay cho việc
dựng ``QDialogButtonBox`` / gọi ``QMessageBox.question`` / ``QInputDialog.get*``
trực tiếp.
"""
from __future__ import annotations
from typing import Callable, Iterable, Tuple
from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QInputDialog, QMessageBox, QWidget,
)
from ..i18n import bind_text, tr
#: tên gọi trong ``**override`` -> (cờ nút chuẩn, khoá dịch mặc định)
_STANDARD = {
"save": (QDialogButtonBox.Save, "dialog.save"),
"cancel": (QDialogButtonBox.Cancel, "dialog.cancel"),
"ok": (QDialogButtonBox.Ok, "dialog.ok"),
"close": (QDialogButtonBox.Close, "dialog.close"),
}
def dialog_buttons(standard, **override: str) -> QDialogButtonBox:
"""``QDialogButtonBox`` có nhãn đổi theo ngôn ngữ đang chọn.
``override`` đổi khoá dịch của một nút cụ thể — ``ok="schedtask.ai_confirm"``
cho hộp thoại muốn nút OK mang chữ riêng. Phải đi qua đây chứ không
``setText(tr(...))`` sau khi dựng: lần đổi ngôn ngữ kế tiếp, ràng buộc bên
dưới sẽ áp lại khoá mặc định và xoá mất chữ riêng đó.
"""
box = QDialogButtonBox(standard)
for name, (flag, key) in _STANDARD.items():
button = box.button(flag)
if button is not None:
bind_text(button, override.get(name, key))
return box
def confirm(parent: QWidget | None, title: str, body: str) -> bool:
"""Hỏi Có/Không, trả về True nếu người dùng đồng ý.
Thay cho ``QMessageBox.question(...) == QMessageBox.Yes``, vốn hiện "&Yes" /
"&No" tiếng Anh ở mọi ngôn ngữ. Hộp thoại dựng lại mỗi lần mở nên gọi
``tr()`` một lần khi dựng là đủ, không cần ràng buộc.
"""
box = QMessageBox(QMessageBox.Question, title, body,
QMessageBox.Yes | QMessageBox.No, parent)
box.button(QMessageBox.Yes).setText(tr("dialog.yes"))
box.button(QMessageBox.No).setText(tr("dialog.no"))
return box.exec() == QMessageBox.Yes
def _hoi(parent: QWidget | None, title: str, label: str,
dung: Callable[[QInputDialog], None]) -> Tuple[str, bool]:
"""Khung chung của ba hàm hỏi bên dưới; ``dung`` chỉnh riêng từng kiểu.
Các hàm tĩnh ``QInputDialog.get*`` tự dựng hộp thoại bên trong nên không
chạm được vào nút của nó — phải tự dựng thì mới đặt được nhãn.
"""
dlg = QInputDialog(parent)
dlg.setWindowTitle(title)
dlg.setLabelText(label)
dlg.setOkButtonText(tr("dialog.ok"))
dlg.setCancelButtonText(tr("dialog.cancel"))
dung(dlg)
# exec() TRƯỚC, đọc giá trị SAU: gói cả hai vào một tuple thì Python tính
# textValue() trước và trả về chữ có sẵn lúc chưa ai gõ gì.
dong_y = dlg.exec() == QDialog.Accepted
return dlg.textValue(), dong_y
def ask_text(parent: QWidget | None, title: str, label: str,
text: str = "") -> Tuple[str, bool]:
"""Như ``QInputDialog.getText``, nhưng nút bấm theo ngôn ngữ đang chọn."""
return _hoi(parent, title, label, lambda d: d.setTextValue(text))
def ask_multiline(parent: QWidget | None, title: str, label: str,
text: str = "") -> Tuple[str, bool]:
"""Như ``QInputDialog.getMultiLineText``, nút bấm theo ngôn ngữ đang chọn."""
def dung(d: QInputDialog) -> None:
d.setOption(QInputDialog.UsePlainTextEditForTextInput)
d.setTextValue(text)
return _hoi(parent, title, label, dung)
def ask_item(parent: QWidget | None, title: str, label: str,
items: Iterable[str], current: int = 0,
editable: bool = False) -> Tuple[str, bool]:
"""Như ``QInputDialog.getItem``, nút bấm theo ngôn ngữ đang chọn."""
muc = list(items)
def dung(d: QInputDialog) -> None:
d.setComboBoxItems(muc)
d.setComboBoxEditable(editable)
if muc:
d.setTextValue(muc[current])
return _hoi(parent, title, label, dung)
+2 -1
View File
@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
from ..core.ext_connectors import CATEGORIES, PRESETS
from ..i18n import tr
from .dialog_buttons import dialog_buttons
# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects
# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker.
@@ -119,7 +120,7 @@ class ExtConnectorEditDialog(QDialog):
self._enabled = bool(connector.get("enabled", False))
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+8 -7
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QWidget,
)
@@ -17,6 +17,7 @@ from ..core import custom_icons
from ..i18n import on_language_changed, tr
from ..state import AppContext
from . import icons as icons_mod
from .dialog_buttons import ask_multiline, ask_text
from .icons import icon
@@ -116,8 +117,8 @@ class IconsAdminTab(QWidget):
path, _ = QFileDialog.getOpenFileName(self, tr("icons_admin.add"), "", "SVG (*.svg)")
if not path:
return
name, ok = QInputDialog.getText(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
name, ok = ask_text(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
if not ok:
return
try:
@@ -129,12 +130,12 @@ class IconsAdminTab(QWidget):
def _add_from_svg_text(self) -> None:
"""Thêm icon bằng cách dán thẳng mã SVG."""
name, ok = QInputDialog.getText(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
name, ok = ask_text(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
if not ok or not name.strip():
return
svg, ok = QInputDialog.getMultiLineText(self, tr("icons_admin.paste"),
tr("icons_admin.paste_prompt"))
svg, ok = ask_multiline(self, tr("icons_admin.paste"),
tr("icons_admin.paste_prompt"))
if not ok:
return
try:
+2 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import tr
from .dialog_buttons import dialog_buttons
class McpServerEditDialog(QDialog):
@@ -41,7 +42,7 @@ class McpServerEditDialog(QDialog):
self.args.setPlaceholderText(tr("mcp.args_placeholder"))
lay.addWidget(self.args)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+11 -8
View File
@@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import tr
from .dialog_buttons import dialog_buttons
from .icons import IconLabel
from .widgets import ToggleSwitch
@@ -108,18 +109,18 @@ class SettingsDialog(QDialog):
sbl = QVBoxLayout(self.sandbox_group)
# --- Password protection for Sandbox Security (at top) ---
self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password")
self.sandbox_pw_label = IconLabel("lock", tr("settings.sandbox_pw_label"))
sbl.addWidget(self.sandbox_pw_label)
pw_row = QHBoxLayout()
self.sandbox_pw_edit = QLineEdit("")
self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings")
self.sandbox_pw_edit.setPlaceholderText(tr("settings.sandbox_pw_placeholder"))
self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
pw_row.addWidget(self.sandbox_pw_edit, 1)
self.sandbox_unlock_btn = QPushButton("Unlock")
self.sandbox_unlock_btn = QPushButton(tr("settings.sandbox_unlock_btn"))
self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
pw_row.addWidget(self.sandbox_unlock_btn)
self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00")
self.sandbox_locked_status = IconLabel("lock", tr("settings.sandbox_locked"), color="#c00")
self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
pw_row.addWidget(self.sandbox_locked_status)
sbl.addLayout(pw_row)
@@ -229,7 +230,7 @@ class SettingsDialog(QDialog):
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._save)
buttons.rejected.connect(self.reject)
outer.addWidget(buttons)
@@ -314,15 +315,17 @@ class SettingsDialog(QDialog):
return
if _sandbox_password_matches(pw, self._sandbox_pw):
self._sandbox_unlocked = True
self.sandbox_locked_status.setText("Unlocked")
self.sandbox_locked_status.setText(tr("settings.sandbox_unlocked"))
self.sandbox_locked_status.set_icon("unlock", "#090")
self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
# Enable all sandbox widgets
for w in self._sandbox_widgets:
w.setEnabled(True)
QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.")
QMessageBox.information(self, tr("settings.group.sandbox"),
tr("settings.sandbox_unlocked_body"))
else:
QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.")
QMessageBox.warning(self, tr("settings.sandbox_pw_wrong_title"),
tr("settings.sandbox_pw_wrong_body"))
def _save(self) -> None:
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
+8 -9
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractItemView, QApplication, QHBoxLayout, QInputDialog, QLabel,
QLineEdit, QMenu, QMessageBox, QPushButton, QStyle, QStyledItemDelegate,
QAbstractItemView, QApplication, QHBoxLayout, QLabel,
QLineEdit, QMenu, QPushButton, QStyle, QStyledItemDelegate,
QStyleOptionViewItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
)
@@ -14,6 +14,7 @@ from ..core.history import (
rename_conversation, set_pinned,
)
from ..i18n import on_language_changed, tr
from .dialog_buttons import ask_text, confirm
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
from .widgets import CollapseStrip
from ..state import AppContext
@@ -346,15 +347,14 @@ class HistorySidebar(QWidget):
set_pinned(path, not pinned)
self.refresh()
elif chosen == rename_act:
new, ok = QInputDialog.getText(
new, ok = ask_text(
self, tr("sidebar.rename.title"), tr("sidebar.rename.label"), text=title)
if ok and new.strip():
rename_conversation(path, new.strip())
self.refresh()
elif chosen == del_act:
if QMessageBox.question(
self, tr("sidebar.delete.title"), tr("sidebar.delete.confirm", title=title)
) == QMessageBox.Yes:
if confirm(self, tr("sidebar.delete.title"),
tr("sidebar.delete.confirm", title=title)):
delete_conversation(path)
self.refresh()
self.history_changed.emit()
@@ -374,9 +374,8 @@ class HistorySidebar(QWidget):
"""Confirm, then delete every conversation in ``selected``. Split out
of _bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question(
self, tr("sidebar.delete.title"),
tr("sidebar.delete_multi.confirm", n=len(selected))) != QMessageBox.Yes:
if not confirm(self, tr("sidebar.delete.title"),
tr("sidebar.delete_multi.confirm", n=len(selected))):
return False
for item in selected:
path = item.data(0, Qt.UserRole)
+3 -2
View File
@@ -12,7 +12,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QInputDialog, QLabel, QListWidget,
QFileDialog, QHBoxLayout, QLabel, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget,
)
@@ -22,6 +22,7 @@ from ..core.skills import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline
from .icons import icon
from .skills_dialog import SkillEditDialog
@@ -100,7 +101,7 @@ class SkillManagerTab(QWidget):
QMessageBox.information(self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_unavailable"))
return
prompt, ok = QInputDialog.getMultiLineText(
prompt, ok = ask_multiline(
self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_prompt"), "")
if not ok or not prompt.strip():
+4 -3
View File
@@ -5,7 +5,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QInputDialog, QLabel,
QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit,
QPushButton, QVBoxLayout,
)
@@ -17,6 +17,7 @@ from ..core.skills import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline, dialog_buttons
from .icons import icon
@@ -58,7 +59,7 @@ class SkillEditDialog(QDialog):
self.instr.setMinimumHeight(180)
lay.addWidget(self.instr)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
@@ -192,7 +193,7 @@ class SkillsDialog(QDialog):
QMessageBox.information(self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_unavailable"))
return
prompt, ok = QInputDialog.getMultiLineText(
prompt, ok = ask_multiline(
self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_prompt"), "")
if not ok or not prompt.strip():
+9 -9
View File
@@ -17,7 +17,7 @@ from typing import Dict, List, Optional
from PySide6.QtCore import QDateTime, Qt
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog,
QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton,
QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
@@ -30,6 +30,7 @@ from ..core.tasks import (
from ..core.projects import list_projects
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_text, dialog_buttons
from .icons import icon
# Only these two task types can be picked when adding/editing a task — Flow,
@@ -451,7 +452,7 @@ class TaskEditorDialog(QDialog):
# matching the five real group boxes, so you always see which group you
# are in and how many are left. (Not tabs: the audit page asks for this
# shape specifically, for consistency with Settings.)
from .widgets import section_panels
from .widgets import guard_wheel, section_panels
self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule",
"schedtask.g_input", "schedtask.g_dependency",
@@ -481,7 +482,7 @@ class TaskEditorDialog(QDialog):
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close"))
buttons.accepted.connect(self._save)
@@ -490,7 +491,6 @@ class TaskEditorDialog(QDialog):
self._apply_hints()
# Scrolling the form must never spin a combo/spin/date box the cursor
# happens to hover — values only change after clicking into a field.
from .widgets import guard_wheel
guard_wheel(self)
def _retranslate_steps(self) -> None:
@@ -540,8 +540,8 @@ class TaskEditorDialog(QDialog):
def _add_link(self) -> None:
"""Thêm một link vào danh sách dữ liệu đầu vào của task."""
url, ok = QInputDialog.getText(self, tr("schedtask.add_link_title"),
tr("schedtask.add_link_label"))
url, ok = ask_text(self, tr("schedtask.add_link_title"),
tr("schedtask.add_link_label"))
url = url.strip()
if ok and url:
self.links_list.addItem(url)
@@ -607,7 +607,7 @@ class TaskEditorDialog(QDialog):
candidates = self.all_tasks + ([self._original] if self._original else [self.task])
err = chain_error(candidates, self.task["task_id"], self.next_combo.currentData())
nxt_id = self.next_combo.currentData()
warn = err or ""
warn = tr(err) if err else ""
if not err and nxt_id:
nxt = next((t for t in self.all_tasks if t["task_id"] == nxt_id), None)
if nxt and nxt.get("status") == "paused":
@@ -719,12 +719,12 @@ class TaskEditorDialog(QDialog):
err = chain_error(self.all_tasks + [self.task], self.task["task_id"],
self.next_combo.currentData())
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
deps = self._checked_depends_on()
err = depends_cycle_error(self.all_tasks + [self.task], self.task["task_id"], deps)
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
t = self.task
t["title"] = title