fix(i18n): đổi ngôn ngữ áp lại toàn bộ chữ trên màn hình
Trước đây rất nhiều widget gọi setText(tr(...)) một lần lúc dựng, nên sau khi đổi ngôn ngữ một nửa màn hình vẫn giữ tiếng cũ. Thêm họ bind_text/bind_tip/ bind_placeholder/bind_items/bind_dynamic trong i18n: khoá dịch được gắn thẳng vào widget (giữ tham chiếu yếu) và tự áp lại mỗi lần set_language. - co4e sidebar, node property panel, run control, routing toggle, nav rail, top bar, filter scaffold, usage chart: chuyển sang binding hoặc tự đăng ký on_language_changed thay vì trông chờ nơi nhúng. - RoutingToggle/AutoRunToggle tự đăng ký retranslate và dùng AdjustToContents để bản dịch dài không bị cắt. - BusyOverlay mới: che cửa sổ trong lúc set_language chạy đồng bộ trên GUI thread, tránh cảm giác treo app. - co4e sidebar chỉ đọc thư viện skill một lần (_skill_prefix_lookup) thay vì quét đĩa cho từng skill — trước đó mỗi lần reload tốn ~3.8s đứng GUI. - Bổ sung bản dịch ja/vi còn để nguyên tiếng Anh; sửa chiều cao hàng Settings ở nav rail. - Thêm 4 bộ test UI cho các thay đổi trên. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+95
-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
|
||||||
@@ -62,6 +73,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 +108,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)"},
|
||||||
|
|||||||
+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"},
|
||||||
|
|||||||
+2
-2
@@ -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"},
|
||||||
|
|||||||
+6
-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…"},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
|||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from ...i18n import tr
|
from ...i18n import bind_text, bind_tip
|
||||||
from ...ui.icons import icon
|
from ...ui.icons import icon
|
||||||
from .palette_list import _PaletteList
|
from .palette_list import _PaletteList
|
||||||
|
|
||||||
@@ -55,9 +55,11 @@ class AgentListPanel(QWidget):
|
|||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
|
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.new_btn = QPushButton(tr("co4e.new"))
|
# Bound, not set once: this panel has no retranslate hook of its own, and
|
||||||
|
# Co4ETab (which owns the language callback) cannot reach these tooltips.
|
||||||
|
self.new_btn = bind_text(QPushButton(), "co4e.new")
|
||||||
self.new_btn.setIcon(icon("plus"))
|
self.new_btn.setIcon(icon("plus"))
|
||||||
self.new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
bind_tip(self.new_btn, "co4e.tt_new_agent")
|
||||||
self.new_btn.setObjectName("co4eSectionAction")
|
self.new_btn.setObjectName("co4eSectionAction")
|
||||||
self.new_btn.setFlat(True)
|
self.new_btn.setFlat(True)
|
||||||
self.new_btn.setCursor(Qt.PointingHandCursor)
|
self.new_btn.setCursor(Qt.PointingHandCursor)
|
||||||
@@ -75,11 +77,11 @@ class AgentListPanel(QWidget):
|
|||||||
# Edit/delete act on the selected row, so they stay with the list.
|
# Edit/delete act on the selected row, so they stay with the list.
|
||||||
self.edit_btn = QPushButton()
|
self.edit_btn = QPushButton()
|
||||||
self.edit_btn.setIcon(icon("edit"))
|
self.edit_btn.setIcon(icon("edit"))
|
||||||
self.edit_btn.setToolTip(tr("co4e.tt_edit_agent"))
|
bind_tip(self.edit_btn, "co4e.tt_edit_agent")
|
||||||
self.edit_btn.setFixedWidth(34)
|
self.edit_btn.setFixedWidth(34)
|
||||||
self.del_btn = QPushButton()
|
self.del_btn = QPushButton()
|
||||||
self.del_btn.setIcon(icon("trash"))
|
self.del_btn.setIcon(icon("trash"))
|
||||||
self.del_btn.setToolTip(tr("co4e.tt_del_agent"))
|
bind_tip(self.del_btn, "co4e.tt_del_agent")
|
||||||
self.del_btn.setFixedWidth(34)
|
self.del_btn.setFixedWidth(34)
|
||||||
# KHONG noi .clicked o day: cung ly do nhu new_btn o tren.
|
# KHONG noi .clicked o day: cung ly do nhu new_btn o tren.
|
||||||
btns.addWidget(self.edit_btn)
|
btns.addWidget(self.edit_btn)
|
||||||
|
|||||||
@@ -13,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").
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from ...config import PROVIDER_LABELS
|
from ...config import PROVIDER_LABELS
|
||||||
from ...core.co4e import PERMISSION_PRESETS, Step
|
from ...core.co4e import PERMISSION_PRESETS, Step
|
||||||
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
|
||||||
@@ -81,26 +81,30 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
|||||||
|
|
||||||
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,15 +112,15 @@ 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, tr("co4e.tab_model_perm"))
|
||||||
|
|
||||||
@@ -126,28 +130,32 @@ 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)
|
||||||
@@ -159,15 +167,15 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
|||||||
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 +183,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
|
||||||
@@ -187,10 +195,10 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
|||||||
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 +211,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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
|
|||||||
QTableWidget, QVBoxLayout, QWidget,
|
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 ClickOutsideCloser, EventTable
|
||||||
from .event_detail_panel import EventDetailPanel
|
from .event_detail_panel import EventDetailPanel
|
||||||
@@ -82,7 +82,10 @@ 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))
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -255,6 +255,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 +272,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) ---
|
||||||
|
|
||||||
|
|||||||
@@ -253,10 +253,24 @@ class NavRailMixin:
|
|||||||
tree.blockSignals(blocked)
|
tree.blockSignals(blocked)
|
||||||
# Both destination lists are exactly as tall as their rows; the
|
# Both destination lists are exactly as tall as their rows; the
|
||||||
# stretch in between belongs to RECENTS.
|
# stretch in between belongs to RECENTS.
|
||||||
|
#
|
||||||
|
# The frame, and nothing else. A flat ``+ 8`` here used to leave 6px
|
||||||
|
# of dead space under the last row of each list, and because the
|
||||||
|
# Settings button sits DIRECTLY under nav_bottom (nvl has no
|
||||||
|
# spacing), that space landed between Giám sát and Settings only —
|
||||||
|
# so three rows that read as one list were spaced 18/26px. Padding
|
||||||
|
# a row is the item delegate's job; this is the frame's.
|
||||||
|
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 same list, so it gets the rows'
|
||||||
|
# own height rather than a second set of paddings guessed to match
|
||||||
|
# it — the only way the three stay evenly spaced when the font (and
|
||||||
|
# with it ``sizeHintForRow``) is not the one this was tuned on.
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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,9 @@ 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))
|
||||||
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 +202,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 +244,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 +255,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:
|
||||||
|
|||||||
@@ -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,325 @@
|
|||||||
|
"""Đổ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.credit", "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",
|
||||||
|
"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
|
||||||
|
# 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"
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""Đổi ngôn ngữ phải có lớp phủ "đang xử lý" — và chỉ khi nó đáng có.
|
||||||
|
|
||||||
|
``set_language()`` chạy ĐỒNG BỘ trên GUI thread: nó gọi lần lượt mọi callback
|
||||||
|
đã đăng ký qua ``on_language_changed``. Trong lúc đó không có vòng lặp sự kiện,
|
||||||
|
nên không ``QTimer`` nào bắn được và không thanh tiến trình nào quay được — lớp
|
||||||
|
phủ phải được ``repaint()`` NGAY, không phải ``update()``.
|
||||||
|
|
||||||
|
Cùng lý do đó, lớp phủ không thể "đợi xem có chậm không rồi mới hiện": khi việc
|
||||||
|
chặn đã bắt đầu thì không còn ai chạy để bật nó lên. Thứ duy nhất đo được là
|
||||||
|
LẦN ĐỔI TRƯỚC. Vì vậy chính sách là: lần đầu trong phiên thì luôn hiện (thận
|
||||||
|
trọng), các lần sau chỉ hiện khi lần trước đã đủ chậm để người ta kịp nhận ra.
|
||||||
|
Máy nhanh vì thế nháy đúng một lần rồi thôi, thay vì nháy mãi mãi.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
|
||||||
|
|
||||||
|
from cowork_local.presentation.shell import top_bar as top_bar_mod
|
||||||
|
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
"""Ngữ cảnh tối thiểu mà ``_switch_language``/``_open_settings`` cần."""
|
||||||
|
|
||||||
|
def __init__(self, language: str = "vi") -> None:
|
||||||
|
self.config = type("C", (), {})()
|
||||||
|
self.config.language = language
|
||||||
|
self.config.active_provider = "p"
|
||||||
|
self.config.theme = "dark"
|
||||||
|
self.config.data = {}
|
||||||
|
self.so_lan_luu = 0
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.so_lan_luu += 1
|
||||||
|
|
||||||
|
|
||||||
|
class _Any:
|
||||||
|
"""Nuốt mọi lời gọi.
|
||||||
|
|
||||||
|
``_open_settings`` còn làm mới sidebar/cowork/workspace sau khi đổi ngôn
|
||||||
|
ngữ; không thứ nào trong số đó là thứ đang được kiểm ở đây.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return _Any()
|
||||||
|
|
||||||
|
def __call__(self, *a, **k):
|
||||||
|
return _Any()
|
||||||
|
|
||||||
|
|
||||||
|
def _dung(qapp):
|
||||||
|
"""Một ``QMainWindow`` thật có mang ``TopBarMixin`` — đúng ``self`` mà mixin
|
||||||
|
nhận trong sản phẩm, nên lớp phủ làm con của cửa sổ chính y như ``Toast``."""
|
||||||
|
from PySide6.QtWidgets import QComboBox, QMainWindow
|
||||||
|
|
||||||
|
class W(top_bar_mod.TopBarMixin, QMainWindow):
|
||||||
|
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
|
||||||
|
|
||||||
|
w = W()
|
||||||
|
w.resize(900, 700)
|
||||||
|
w.ctx = _Ctx()
|
||||||
|
combo = QComboBox()
|
||||||
|
for code in ("en", "ja", "vi"):
|
||||||
|
combo.addItem(code.upper(), code)
|
||||||
|
combo.setCurrentIndex(combo.findData("vi"))
|
||||||
|
w.language_combo = combo
|
||||||
|
pcombo = QComboBox()
|
||||||
|
pcombo.addItem("P", "p")
|
||||||
|
w.provider_combo = pcombo
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def _dong_ho(*moc):
|
||||||
|
"""Đồng hồ giả trả lần lượt các mốc thời gian (giây) cho trước."""
|
||||||
|
it = iter(moc)
|
||||||
|
return lambda: next(it)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dat_ngon_ngu():
|
||||||
|
"""Đặt ngôn ngữ hiện tại mà KHÔNG phát tán cho listener.
|
||||||
|
|
||||||
|
``set_language()`` gọi mọi callback đã đăng ký — trong một phiên pytest đầy
|
||||||
|
đủ, danh sách đó chứa widget của các test khác. Ở đây chỉ cần biết ``tr()``
|
||||||
|
đang trả về ngôn ngữ nào.
|
||||||
|
"""
|
||||||
|
from cowork_local import i18n as i18n_mod
|
||||||
|
|
||||||
|
cu = i18n_mod._current
|
||||||
|
|
||||||
|
def dat(code: str) -> None:
|
||||||
|
i18n_mod._current = code
|
||||||
|
|
||||||
|
yield dat
|
||||||
|
i18n_mod._current = cu
|
||||||
|
|
||||||
|
|
||||||
|
# ---- lớp phủ bật TRƯỚC khi việc chặn bắt đầu -----------------------------
|
||||||
|
|
||||||
|
def test_overlay_hien_truoc_khi_set_language_chan(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""Đi qua đúng đường người dùng bấm: combo ngôn ngữ ở chân nav rail.
|
||||||
|
|
||||||
|
``isHidden()`` chứ không ``isVisible()``: cửa sổ cha chưa ``show()`` thì
|
||||||
|
``isVisible()`` của widget con luôn False, đúng idiom mà
|
||||||
|
``test_graphrag_busy_panel.py:41`` đã dùng.
|
||||||
|
"""
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
ghi = {}
|
||||||
|
|
||||||
|
def probe(lang):
|
||||||
|
ghi["hien"] = not w._lang_busy.isHidden()
|
||||||
|
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||||
|
try:
|
||||||
|
w.language_combo.setCurrentIndex(w.language_combo.findData("en"))
|
||||||
|
w._on_language_changed(0)
|
||||||
|
|
||||||
|
assert ghi["hien"] is True, (
|
||||||
|
"lớp phủ bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì")
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nguoi_dung_that_su_nhin_thay_lop_phu(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""Cửa sổ đã hiện thì ``isVisible()`` mới có nghĩa — chốt luôn mức đó."""
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
ghi = {}
|
||||||
|
|
||||||
|
def probe(lang):
|
||||||
|
ghi["hien"] = w._lang_busy.isVisible()
|
||||||
|
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||||
|
w.show()
|
||||||
|
try:
|
||||||
|
w._switch_language("en")
|
||||||
|
|
||||||
|
assert ghi["hien"] is True
|
||||||
|
finally:
|
||||||
|
w.hide()
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_tat_sau_khi_doi_xong(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
|
||||||
|
try:
|
||||||
|
w._switch_language("en")
|
||||||
|
|
||||||
|
assert w._lang_busy.isHidden() is True
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_nem_loi_van_tat_overlay(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""``i18n/__init__.py:68`` chỉ nuốt ``RuntimeError``. Một listener ném loại
|
||||||
|
khác sẽ xuyên qua ``set_language()`` — không có ``try/finally`` thì lớp phủ
|
||||||
|
treo lại trên màn hình vĩnh viễn."""
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
|
||||||
|
def probe(lang):
|
||||||
|
raise ValueError("một listener nào đó hỏng")
|
||||||
|
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
w._switch_language("en")
|
||||||
|
|
||||||
|
assert w._lang_busy.isHidden() is True, "lớp phủ treo lại mãi mãi"
|
||||||
|
assert w.language_combo.isEnabled() is True, "combo khoá lại mãi mãi"
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- chữ hiện bằng NGÔN NGỮ CŨ -------------------------------------------
|
||||||
|
|
||||||
|
def _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu: str, sang: str) -> str:
|
||||||
|
"""Chữ trên lớp phủ tại thời điểm việc chặn bắt đầu.
|
||||||
|
|
||||||
|
Probe đổi ``_current`` thật, y như ``set_language()`` làm, nên nếu ``tr()``
|
||||||
|
bị gọi SAU lượt đổi thì chữ sẽ ra ngôn ngữ mới và test đỏ.
|
||||||
|
"""
|
||||||
|
from cowork_local import i18n as i18n_mod
|
||||||
|
|
||||||
|
dat_ngon_ngu(tu)
|
||||||
|
w = _dung(qapp)
|
||||||
|
ghi = {}
|
||||||
|
|
||||||
|
def probe(lang):
|
||||||
|
i18n_mod._current = lang
|
||||||
|
ghi["chu"] = w._lang_busy.text()
|
||||||
|
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||||
|
try:
|
||||||
|
w._switch_language(sang)
|
||||||
|
return ghi["chu"]
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chu_hien_bang_ngon_ngu_cu(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
from cowork_local.i18n import STRINGS
|
||||||
|
|
||||||
|
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="vi", sang="en")
|
||||||
|
|
||||||
|
assert chu == STRINGS["app.lang.switching"]["vi"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chu_hien_bang_ngon_ngu_cu_chieu_nguoc(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
from cowork_local.i18n import STRINGS
|
||||||
|
|
||||||
|
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="en", sang="vi")
|
||||||
|
|
||||||
|
assert chu == STRINGS["app.lang.switching"]["en"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i18n_du_ba_ngon_ngu():
|
||||||
|
from cowork_local.i18n import STRINGS
|
||||||
|
|
||||||
|
assert set(STRINGS["app.lang.switching"]) >= {"en", "ja", "vi"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- hình dạng: phủ kín cửa sổ, khoá combo -------------------------------
|
||||||
|
|
||||||
|
def test_overlay_phu_kin_cua_so(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""Phủ nửa vời thì người dùng vẫn bấm được vào phần còn lại."""
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
|
||||||
|
try:
|
||||||
|
w._switch_language("en")
|
||||||
|
|
||||||
|
assert w._lang_busy.geometry() == w.rect()
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_combo_bi_khoa_trong_luc_chan(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
ghi = {}
|
||||||
|
|
||||||
|
def probe(lang):
|
||||||
|
ghi["khoa"] = w.language_combo.isEnabled()
|
||||||
|
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||||
|
try:
|
||||||
|
w._switch_language("en")
|
||||||
|
|
||||||
|
assert ghi["khoa"] is False
|
||||||
|
assert w.language_combo.isEnabled() is True, "phải mở khoá lại sau khi xong"
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- chính sách: chỉ nháy một lần trên máy nhanh -------------------------
|
||||||
|
|
||||||
|
def test_lan_dau_trong_phien_luon_hien(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""Chưa đo được gì thì nghiêng về phía hiện: một cái nháy còn hơn một lần
|
||||||
|
đơ vài giây không lời giải thích."""
|
||||||
|
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||||
|
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
ghi = {}
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||||
|
lambda lang: ghi.setdefault("hien", not w._lang_busy.isHidden()))
|
||||||
|
try:
|
||||||
|
assert w._lang_busy_overlay()._last_ms is None
|
||||||
|
w._switch_language("en")
|
||||||
|
|
||||||
|
assert ghi["hien"] is True
|
||||||
|
assert busy_mod._NOTICEABLE_MS > 0
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lan_truoc_nhanh_thi_khong_hien_nua(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""20 ms thì không ai kịp thấy mình đang đợi — hiện lớp phủ ở đó là tự tạo
|
||||||
|
ra một cái nháy toàn màn hình."""
|
||||||
|
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||||
|
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 0.02, 5.0, 5.02))
|
||||||
|
ghi = []
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||||
|
lambda lang: ghi.append(not w._lang_busy.isHidden()))
|
||||||
|
try:
|
||||||
|
w._switch_language("en")
|
||||||
|
w._switch_language("ja")
|
||||||
|
|
||||||
|
assert ghi == [True, False]
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lan_truoc_cham_thi_van_hien(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""Máy chậm / thư viện skill lớn: lần trước mất 1 giây thì lần này phải có
|
||||||
|
lớp phủ, không đợi thêm lần nào nữa."""
|
||||||
|
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||||
|
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w = _dung(qapp)
|
||||||
|
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 1.0, 5.0, 6.0))
|
||||||
|
ghi = []
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||||
|
lambda lang: ghi.append(not w._lang_busy.isHidden()))
|
||||||
|
try:
|
||||||
|
w._switch_language("en")
|
||||||
|
w._switch_language("ja")
|
||||||
|
|
||||||
|
assert ghi == [True, True]
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- đường Cài đặt ▸ Chung -----------------------------------------------
|
||||||
|
|
||||||
|
def _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu: str):
|
||||||
|
"""Chạy ``_open_settings`` với hộp thoại bị thay, trả về (window, ghi)."""
|
||||||
|
w = _dung(qapp)
|
||||||
|
w._apply_theme = lambda: None
|
||||||
|
w.theme_btn = _Any()
|
||||||
|
w.cowork = _Any()
|
||||||
|
w.workspace = _Any()
|
||||||
|
w.sidebar = _Any()
|
||||||
|
ghi = {"goi": 0}
|
||||||
|
|
||||||
|
class _Dlg:
|
||||||
|
def __init__(self, ctx, parent):
|
||||||
|
self._ctx = ctx
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
self._ctx.config.language = ngon_ngu_sau_khi_luu
|
||||||
|
return True
|
||||||
|
|
||||||
|
def probe(lang):
|
||||||
|
ghi["goi"] += 1
|
||||||
|
ghi["hien"] = not w._lang_busy.isHidden()
|
||||||
|
|
||||||
|
monkeypatch.setattr(top_bar_mod, "SettingsDialog", _Dlg)
|
||||||
|
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||||
|
return w, ghi
|
||||||
|
|
||||||
|
|
||||||
|
def test_duong_cai_dat_cung_co_overlay(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""Cài đặt ▸ Chung đổi ngôn ngữ thì cũng chặn GUI thread y hệt combo."""
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="en")
|
||||||
|
try:
|
||||||
|
w._open_settings()
|
||||||
|
|
||||||
|
assert ghi["goi"] == 1
|
||||||
|
assert ghi["hien"] is True
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_duong_cai_dat_khong_nhay_khi_khong_doi_ngon_ngu(qapp, monkeypatch, dat_ngon_ngu):
|
||||||
|
"""Bấm Lưu mà không đụng tới ngôn ngữ: ``set_language()`` là no-op
|
||||||
|
(``i18n/__init__.py:62``), nên bật lớp phủ ở đây chỉ là một cái nháy."""
|
||||||
|
dat_ngon_ngu("vi")
|
||||||
|
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="vi")
|
||||||
|
try:
|
||||||
|
w._open_settings()
|
||||||
|
|
||||||
|
assert ghi["goi"] == 0, "gọi set_language() cho một lượt đổi không tồn tại"
|
||||||
|
assert getattr(w, "_lang_busy", None) is None
|
||||||
|
finally:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- màu lấy từ theme, không hardcode ------------------------------------
|
||||||
|
|
||||||
|
def test_mau_lay_tu_theme():
|
||||||
|
"""Guardrail G4: ngoài ``theme/`` không file nào được đặt tên một màu."""
|
||||||
|
from cowork_local.theme.qss import _TEMPLATE
|
||||||
|
|
||||||
|
assert "QWidget#busyOverlayPanel" in _TEMPLATE.template
|
||||||
|
src = (Path(__file__).resolve().parents[2]
|
||||||
|
/ "presentation" / "shell" / "busy_overlay.py").read_text(encoding="utf-8")
|
||||||
|
assert "setStyleSheet" not in src
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Regression: Định tuyến / Tự chạy phải đổi chữ theo ngôn ngữ runtime (UI-20260907-01).
|
||||||
|
|
||||||
|
Người dùng dựng app ở một ngôn ngữ rồi đổi sang ngôn ngữ khác: chữ trên công tắc
|
||||||
|
"Định tuyến" và ô "Tự chạy" ở tab Cowork vẫn đóng băng ở ngôn ngữ lúc dựng widget.
|
||||||
|
Vì vậy MỌI test ở đây phải theo khuôn **dựng ở X → đổi sang Y → kiểm**: kiểm ngay
|
||||||
|
lúc vừa dựng là vô nghĩa (widget luôn đúng lúc dựng, kể cả khi bản vá sai).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cowork_local import i18n
|
||||||
|
from cowork_local.ui.routing_toggle import AutoRunToggle, RoutingToggle
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCtx:
|
||||||
|
"""AppContext tối thiểu; ghi lại MỌI lần ghi để test chứng minh dịch lại không ghi đĩa."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Khởi tạo với sổ ghi rỗng."""
|
||||||
|
self.writes: list = []
|
||||||
|
|
||||||
|
def project_routing_mode(self, surface: str) -> str:
|
||||||
|
"""Chế độ định tuyến của bề mặt đang mở — cố định "manual" cho test."""
|
||||||
|
return "manual"
|
||||||
|
|
||||||
|
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||||
|
"""Ghi lại lần ghi chế độ định tuyến thay vì chạm đĩa."""
|
||||||
|
self.writes.append((surface, mode))
|
||||||
|
|
||||||
|
def project_auto_run(self) -> bool:
|
||||||
|
"""Trạng thái tự chạy của project — cố định True cho test."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_project_auto_run(self, value: bool) -> None:
|
||||||
|
"""Ghi lại lần ghi cờ tự chạy thay vì chạm đĩa."""
|
||||||
|
self.writes.append(("auto_run", value))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def i18n_sach():
|
||||||
|
"""Trả ngôn ngữ VÀ danh sách listener về nguyên trạng.
|
||||||
|
|
||||||
|
``i18n._listeners`` chỉ có đường vào (i18n/__init__.py:100); widget do test
|
||||||
|
dựng sẽ nằm lại đó và bị gọi ở mọi test sau. Khôi phục để test hermetic.
|
||||||
|
"""
|
||||||
|
lang, listeners = i18n.get_language(), list(i18n._listeners)
|
||||||
|
yield
|
||||||
|
i18n._listeners[:] = listeners
|
||||||
|
i18n.set_language(lang)
|
||||||
|
|
||||||
|
|
||||||
|
def _chu_dinh_tuyen_phai_la(toggle: RoutingToggle, lang: str) -> None:
|
||||||
|
"""Mọi chữ trên công tắc định tuyến phải khớp bản dịch của ``lang``.
|
||||||
|
|
||||||
|
So với ``i18n.STRINGS`` chứ không hard-code chuỗi: đổi từ ngữ sau này không
|
||||||
|
được làm test đỏ oan.
|
||||||
|
"""
|
||||||
|
assert toggle._label.text() == i18n.STRINGS["routing.toggle_label"][lang]
|
||||||
|
assert toggle._combo.toolTip() == i18n.STRINGS["routing.toggle_tooltip"][lang]
|
||||||
|
for i, (_value, key) in enumerate(toggle._modes):
|
||||||
|
assert toggle._combo.itemText(i) == i18n.STRINGS[key][lang], (
|
||||||
|
f"item {i} sai ngôn ngữ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_dinh_layout(app, lap: int = 3) -> None:
|
||||||
|
"""Ép Qt xử lý hết layout request đang xếp hàng để bề rộng widget ổn định."""
|
||||||
|
from PySide6.QtCore import QEvent
|
||||||
|
|
||||||
|
for _ in range(lap):
|
||||||
|
app.sendPostedEvents(None, QEvent.LayoutRequest)
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _be_rong_o_chu(combo) -> int:
|
||||||
|
"""Bề rộng vùng hiển thị chữ của combo (đã trừ khung và mũi tên)."""
|
||||||
|
from PySide6.QtWidgets import QStyle, QStyleOptionComboBox
|
||||||
|
|
||||||
|
opt = QStyleOptionComboBox()
|
||||||
|
opt.initFrom(combo)
|
||||||
|
opt.currentText = combo.currentText()
|
||||||
|
opt.editable = combo.isEditable()
|
||||||
|
opt.frame = combo.hasFrame()
|
||||||
|
opt.subControls = QStyle.SC_All
|
||||||
|
return combo.style().subControlRect(
|
||||||
|
QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, combo
|
||||||
|
).width()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_vi(qapp, i18n_sach):
|
||||||
|
"""Dựng ở tiếng Nhật, đổi sang tiếng Việt → nhãn/tooltip/4 chế độ phải là tiếng Việt."""
|
||||||
|
i18n.set_language("ja")
|
||||||
|
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||||
|
|
||||||
|
i18n.set_language("vi")
|
||||||
|
|
||||||
|
_chu_dinh_tuyen_phai_la(toggle, "vi")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_en(qapp, i18n_sach):
|
||||||
|
"""Dựng ở tiếng Nhật, đổi sang tiếng Anh → toàn bộ chữ phải là tiếng Anh."""
|
||||||
|
i18n.set_language("ja")
|
||||||
|
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||||
|
|
||||||
|
i18n.set_language("en")
|
||||||
|
|
||||||
|
_chu_dinh_tuyen_phai_la(toggle, "en")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_vi_roi_doi_sang_ja(qapp, i18n_sach):
|
||||||
|
"""Chiều ngược lại: dựng ở tiếng Việt, đổi sang tiếng Nhật."""
|
||||||
|
i18n.set_language("vi")
|
||||||
|
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||||
|
|
||||||
|
i18n.set_language("ja")
|
||||||
|
|
||||||
|
_chu_dinh_tuyen_phai_la(toggle, "ja")
|
||||||
|
|
||||||
|
|
||||||
|
def test_doi_ngon_ngu_khong_lam_doi_che_do_dinh_tuyen(qapp, i18n_sach):
|
||||||
|
"""Test KHOÁ: dịch lại chỉ được đổi CHỮ, không đổi chế độ và không ghi đĩa.
|
||||||
|
|
||||||
|
Cột ``data`` ("off"/"auto"/"manual"/"fallback") được persist xuống đĩa; nếu
|
||||||
|
ai đó dịch luôn cột đó, hoặc đổi ``retranslate()`` sang ``clear()+addItem()``,
|
||||||
|
thì mode routing của mọi workspace bị âm thầm reset về "off".
|
||||||
|
"""
|
||||||
|
i18n.set_language("ja")
|
||||||
|
ctx = FakeCtx()
|
||||||
|
toggle = RoutingToggle(ctx, "cowork")
|
||||||
|
|
||||||
|
for lang in ("vi", "en", "ja"):
|
||||||
|
i18n.set_language(lang)
|
||||||
|
|
||||||
|
assert toggle.current_mode() == "manual"
|
||||||
|
assert toggle._combo.currentData() == "manual"
|
||||||
|
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại chế độ định tuyến"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tu_chay_dung_ngon_ngu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
|
||||||
|
"""Ô "Tự chạy" cũng phải đổi chữ, và không được tự đổi trạng thái tick."""
|
||||||
|
i18n.set_language("ja")
|
||||||
|
ctx = FakeCtx()
|
||||||
|
toggle = AutoRunToggle(ctx)
|
||||||
|
|
||||||
|
i18n.set_language("vi")
|
||||||
|
|
||||||
|
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["vi"]
|
||||||
|
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["vi"]
|
||||||
|
|
||||||
|
i18n.set_language("en")
|
||||||
|
|
||||||
|
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["en"]
|
||||||
|
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["en"]
|
||||||
|
assert toggle._chk.isChecked() is True
|
||||||
|
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại cờ tự chạy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bon_che_do_khong_bi_cat_chu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
|
||||||
|
"""Dịch lại xong thì combo phải rộng ra theo chữ mới, không nuốt đuôi.
|
||||||
|
|
||||||
|
Combo dựng ở tiếng Nhật rồi đổi sang tiếng Việt: tên chế độ tiếng Việt dài
|
||||||
|
hơn tên tiếng Nhật, nên nếu bề rộng còn bị khoá theo lần hiện đầu tiên thì
|
||||||
|
"Thủ công"/"Dự phòng" (96px) không lọt ô chữ 89px và bị cắt đuôi.
|
||||||
|
|
||||||
|
Hai lớp assert, mỗi lớp bắt một nửa bản vá:
|
||||||
|
|
||||||
|
* chữ phải là tiếng Việt — bắt việc quên đăng ký ``on_language_changed``;
|
||||||
|
* chữ phải lọt ô — bắt việc quên ``setSizeAdjustPolicy``, vì combo chỉ đo
|
||||||
|
lại bề rộng khi chính sách là ``AdjustToContents``.
|
||||||
|
|
||||||
|
``host`` được nới rộng có chủ ý: ``addStretch`` phải còn chỗ trống để nhả ra
|
||||||
|
cho combo, nếu không thì cả combo đúng lẫn combo sai đều kẹt ở bề rộng cũ và
|
||||||
|
test không phân biệt được hai trạng thái.
|
||||||
|
"""
|
||||||
|
from PySide6.QtGui import QFontMetrics
|
||||||
|
from PySide6.QtWidgets import QHBoxLayout, QWidget
|
||||||
|
|
||||||
|
i18n.set_language("ja")
|
||||||
|
host = QWidget()
|
||||||
|
lay = QHBoxLayout(host)
|
||||||
|
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||||
|
lay.addWidget(toggle)
|
||||||
|
lay.addStretch(1)
|
||||||
|
host.resize(600, 60)
|
||||||
|
host.show()
|
||||||
|
_on_dinh_layout(qapp)
|
||||||
|
|
||||||
|
i18n.set_language("vi")
|
||||||
|
_on_dinh_layout(qapp)
|
||||||
|
|
||||||
|
combo = toggle._combo
|
||||||
|
fm = QFontMetrics(combo.font())
|
||||||
|
o_chu = _be_rong_o_chu(combo)
|
||||||
|
chu_hien = [combo.itemText(i) for i in range(combo.count())]
|
||||||
|
bi_cat = [t for t in chu_hien if fm.horizontalAdvance(t) > o_chu]
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
assert chu_hien == [i18n.STRINGS[key]["vi"] for _value, key in toggle._modes]
|
||||||
|
assert not bi_cat, f"bị cắt chữ: {bi_cat} (ô chữ rộng {o_chu}px)"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", [
|
||||||
|
"routing.toggle_label", "routing.toggle_tooltip",
|
||||||
|
"routing.autorun_label", "routing.autorun_tooltip",
|
||||||
|
"routing.mode_off", "routing.mode_auto",
|
||||||
|
"routing.mode_manual", "routing.mode_fallback",
|
||||||
|
])
|
||||||
|
def test_key_routing_co_du_ba_ngon_ngu(key):
|
||||||
|
"""Test KHOÁ: thiếu một bản dịch thì ``tr()`` rơi về tiếng Anh, lỗi lại tái diễn."""
|
||||||
|
for lang in ("en", "ja", "vi"):
|
||||||
|
assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}"
|
||||||
+8
-4
@@ -183,10 +183,14 @@ QPushButton#navSettingsBtn {
|
|||||||
/* Padding stays at 0: the row lays its own icon and label out, so that
|
/* Padding stays at 0: the row lays its own icon and label out, so that
|
||||||
the spacing does not change with the platform's button style. */
|
the spacing does not change with the platform's button style. */
|
||||||
padding: 0; text-align: left; border-radius: ${radius}px;
|
padding: 0; text-align: left; border-radius: ${radius}px;
|
||||||
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
|
/* No margin at all. A vertical one here was doing the opposite of what it
|
||||||
so its icon has to start on their x. A 6px margin put it at 14 — near
|
read as: QVBoxLayout gave this button its geometry with the margin
|
||||||
enough the middle of the collapsed 54px rail to look centred. */
|
ignored (nav_bottom's bottom edge and this button's top edge measured
|
||||||
margin: 2px 0px 6px 0px;
|
the same y), while the PAINTER honoured it — so the only thing 10px/14px
|
||||||
|
actually did was inset the hover fill, leaving Settings with a visibly
|
||||||
|
shorter hover pill than the rows above. Spacing above/below the row is
|
||||||
|
nav_rail's, which pins this button to the rows' own height. */
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
|
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
|
||||||
QPushButton#navSettingsBtn:pressed { background: $active; }
|
QPushButton#navSettingsBtn:pressed { background: $active; }
|
||||||
|
|||||||
@@ -324,6 +324,17 @@ QLabel#welcomeCardTitle { color: $text; font-weight: 600; }
|
|||||||
nen dac cua rieng minh thay vi chi la mot dong chu. */
|
nen dac cua rieng minh thay vi chi la mot dong chu. */
|
||||||
QWidget#graphBusy { background: $overlay; border: 1px solid $border_strong; border-radius: ${radius}px; }
|
QWidget#graphBusy { background: $overlay; border: 1px solid $border_strong; border-radius: ${radius}px; }
|
||||||
QWidget#graphBusy QLabel { color: $text; font-weight: 600; }
|
QWidget#graphBusy QLabel { color: $text; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Lop phu ca cua so trong luc doi ngon ngu — cung ly do voi #graphBusy o tren:
|
||||||
|
viec chan chay DONG BO tren GUI thread. Nen mo chu khong dac: nguoi dung con
|
||||||
|
thay man hinh cu mo di, nen hieu la app dang lam viec chu khong phai da nhay
|
||||||
|
sang mot man hinh khac. */
|
||||||
|
QWidget#busyOverlay { background: rgba(0, 0, 0, 0.45); }
|
||||||
|
QWidget#busyOverlayPanel {
|
||||||
|
background: $overlay; border: 1px solid $border_strong;
|
||||||
|
border-radius: ${radius}px;
|
||||||
|
}
|
||||||
|
QWidget#busyOverlayPanel QLabel { color: $text; font-weight: 600; }
|
||||||
QLabel#faint { color: $text_faint; }
|
QLabel#faint { color: $text_faint; }
|
||||||
QLabel#warning { color: $warning; font-weight: 600; }
|
QLabel#warning { color: $warning; font-weight: 600; }
|
||||||
QLabel#error { color: $danger; font-weight: 600; }
|
QLabel#error { color: $danger; font-weight: 600; }
|
||||||
|
|||||||
+27
-13
@@ -390,21 +390,35 @@ class Co4ETab(
|
|||||||
|
|
||||||
# ---- i18n -------------------------------------------------------------
|
# ---- i18n -------------------------------------------------------------
|
||||||
def _retranslate(self) -> None:
|
def _retranslate(self) -> None:
|
||||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip."""
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip.
|
||||||
for key in self._sections:
|
|
||||||
self._sync_section_arrow(key)
|
Cột trái và trang Flow Status không có mặt ở đây: mỗi widget bên đó tự
|
||||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
ràng buộc khoá dịch của mình tại chỗ dựng (``i18n.bind_*``), nên panel
|
||||||
self.wf_new_btn.setText(tr("co4e.new"))
|
dùng ở đâu cũng đúng ngôn ngữ mà không cần ai nhớ hộ.
|
||||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
"""
|
||||||
self.runs_btn.setText(tr("co4e.runs_tab"))
|
self.runs_btn.setText(tr("co4e.runs_tab"))
|
||||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||||
self.runs_back_btn.setText(tr("co4e.back_to_flow"))
|
# Flow toolbar.
|
||||||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
|
||||||
self.runs_title.setText(tr("co4e.running_flows"))
|
self.add_step_btn.setText(tr("co4e.add"))
|
||||||
self.run_stop_btn.setText(tr("co4e.stop"))
|
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
|
||||||
self.run_rename_btn.setText(tr("co4e.rename_run"))
|
self.save_btn.setText(tr("co4e.save"))
|
||||||
self.run_del_btn.setText(tr("co4e.delete_run"))
|
self.save_btn.setToolTip(tr("co4e.tt_save"))
|
||||||
self.run_clear_btn.setText(tr("co4e.clear_done"))
|
self.save_tpl_btn.setToolTip(tr("co4e.tt_save_template"))
|
||||||
|
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
|
||||||
|
# By position, from the same source the items were built from: the mode
|
||||||
|
# string in each item's data is persisted, so it must survive a
|
||||||
|
# translation untouched.
|
||||||
|
for i, mode in enumerate(co4e.RUN_MODES):
|
||||||
|
self.mode_combo.setItemText(i, tr(f"co4e.mode.{mode}"))
|
||||||
|
self.run_btn.setToolTip(tr("co4e.tt_run"))
|
||||||
|
# Not a plain tr(): this button reads "Dừng" while THIS flow is running.
|
||||||
|
self._update_run_btn()
|
||||||
|
# Step-config panel header. The toggle's tooltip names the action it
|
||||||
|
# would perform, which depends on which way the panel is folded.
|
||||||
|
self.config_title.setText(tr("co4e.config_title"))
|
||||||
|
self.config_toggle_btn.setToolTip(tr(
|
||||||
|
"co4e.tt_expand_config" if self._config_collapsed else "co4e.tt_collapse_config"))
|
||||||
self._refresh_ws_folder_btn()
|
self._refresh_ws_folder_btn()
|
||||||
self.runs_table.setHorizontalHeaderLabels([
|
self.runs_table.setHorizontalHeaderLabels([
|
||||||
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
|
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
||||||
from ..core.worker import AgentWorker
|
from ..core.worker import AgentWorker
|
||||||
from ..i18n import on_language_changed, tr
|
from ..i18n import bind_text, on_language_changed, tr
|
||||||
from ..state import AppContext
|
from ..state import AppContext
|
||||||
from .ext_connector_dialog import ExtConnectorEditDialog
|
from .ext_connector_dialog import ExtConnectorEditDialog
|
||||||
from .icons import icon
|
from .icons import icon
|
||||||
@@ -160,7 +160,7 @@ class ConnectorsPanel(QWidget):
|
|||||||
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
|
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
|
||||||
lay.addWidget(self.connect_external_sw)
|
lay.addWidget(self.connect_external_sw)
|
||||||
|
|
||||||
hint = QLabel(tr("settings.ext_hint"))
|
hint = bind_text(QLabel(), "settings.ext_hint")
|
||||||
hint.setObjectName("hint")
|
hint.setObjectName("hint")
|
||||||
hint.setWordWrap(True)
|
hint.setWordWrap(True)
|
||||||
lay.addWidget(hint)
|
lay.addWidget(hint)
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ class CoworkTab(ChatPanel):
|
|||||||
|
|
||||||
def _retranslate(self) -> None:
|
def _retranslate(self) -> None:
|
||||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
||||||
|
# ChatPanel dựng phần chrome dùng chung (nhãn Agent, nút Nén, tiêu đề ba
|
||||||
|
# bảng tệp) nhưng KHÔNG tự đăng ký dịch lại — lớp con là chỗ duy nhất có
|
||||||
|
# đăng ký, nên bỏ dòng này là toàn bộ phần đó đứng ở ngôn ngữ lúc dựng.
|
||||||
|
self._retranslate_base()
|
||||||
self.refresh_title()
|
self.refresh_title()
|
||||||
self.skills_btn.setText(tr("cowork.skills_btn"))
|
self.skills_btn.setText(tr("cowork.skills_btn"))
|
||||||
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
||||||
|
|||||||
+12
-1
@@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..i18n import tr
|
from ..i18n import on_language_changed, tr
|
||||||
|
|
||||||
|
|
||||||
class RoutingToggle(QWidget):
|
class RoutingToggle(QWidget):
|
||||||
@@ -71,6 +71,11 @@ class RoutingToggle(QWidget):
|
|||||||
self._label.setObjectName("hint")
|
self._label.setObjectName("hint")
|
||||||
self._combo = QComboBox()
|
self._combo = QComboBox()
|
||||||
self._combo.setToolTip(tr("routing.toggle_tooltip"))
|
self._combo.setToolTip(tr("routing.toggle_tooltip"))
|
||||||
|
# Mode names differ in length per language ("Thủ công" is wider than
|
||||||
|
# "手動"), and a combo only re-measures itself under this policy — without
|
||||||
|
# it the width stays frozen at the language the widget was built in and
|
||||||
|
# the longer translation is cut off.
|
||||||
|
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
# (data value, i18n key) — data is the persisted mode string. Order is
|
||||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
||||||
# the "only when something breaks" mode rather than a stronger Auto.
|
# the "only when something breaks" mode rather than a stronger Auto.
|
||||||
@@ -88,6 +93,11 @@ class RoutingToggle(QWidget):
|
|||||||
self._combo.currentIndexChanged.connect(self._on_changed)
|
self._combo.currentIndexChanged.connect(self._on_changed)
|
||||||
lay.addWidget(self._label)
|
lay.addWidget(self._label)
|
||||||
lay.addWidget(self._combo)
|
lay.addWidget(self._combo)
|
||||||
|
# Registered HERE, not by the four surfaces that embed this widget
|
||||||
|
# (Cowork, Co4E, AI-Edit): every one of them had forgotten to, so the
|
||||||
|
# control stayed frozen in the language it was built in. Owning it here
|
||||||
|
# means no future embedder can forget either.
|
||||||
|
on_language_changed(self.retranslate)
|
||||||
|
|
||||||
def current_mode(self) -> str:
|
def current_mode(self) -> str:
|
||||||
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
||||||
@@ -149,6 +159,7 @@ class AutoRunToggle(QWidget):
|
|||||||
self.refresh()
|
self.refresh()
|
||||||
self._chk.toggled.connect(self._on_toggled)
|
self._chk.toggled.connect(self._on_toggled)
|
||||||
lay.addWidget(self._chk)
|
lay.addWidget(self._chk)
|
||||||
|
on_language_changed(self.retranslate) # same reason as RoutingToggle
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
"""Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""
|
"""Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""
|
||||||
|
|||||||
Reference in New Issue
Block a user