Compare commits

...
12 Commits
Author SHA1 Message Date
Nam Pham Dinh ThanhandClaude Opus 5 af8a3712e2 fix(co4e): 4 chỗ ghi JSON của Gamma đi qua AtomicJsonFile — tiêu chí nghiệm thu A
Soát lại plan.md thì thấy CASAN là NĂM tiêu chí C-A-S-A-N, không phải ba. Tiêu
chí A có hai vế, tôi mới đạt vế đầu:

  vế 1  0 API key plaintext trong JSON          -> đã đạt từ 25/08
  vế 2  MỌI thao tác ghi tệp đi qua AtomicJsonFile  -> CHƯA

Toàn repo còn 15 chỗ ghi JSON thẳng. Bốn trong đó là của Gamma (vùng Co4E):

  core/co4e.py:236   lưu workflow   ghi thẳng, không nguyên tử gì cả
  core/co4e.py:310   lưu agent      ghi thẳng
  core/co4e_run_manager.py:156                  tmp + replace tự viết
  application/workflows/co4e_workflow_service.py:178   tmp + replace tự viết

Hai chỗ đầu nguy hơn: tắt máy giữa lúc lưu là mất luôn workflow hoặc agent.

Hai chỗ sau nhìn thì có vẻ ổn vì đã tmp + replace, nhưng thiếu hai thứ:
* không fsync — dữ liệu có thể còn nằm trong bộ đệm ổ đĩa khi mất điện, nên
  "nguyên tử" chỉ đúng với crash tiến trình, không đúng với mất điện;
* dùng thẳng Path.replace, đúng chỗ dính PermissionError [WinError 5] mà tôi
  vá hôm 25/08 — Defender giữ handle file vừa tạo. Tần suất đo được khoảng
  1/140 lần lưu, nhân với số lần lưu lịch sử chạy flow.

11 chỗ còn lại thuộc team khác (accounts, admin_agents, custom_agents, flows,
groups, history, projects, skills, tasks). Không đụng vào; cần báo lên vì
tiêu chí A là tiêu chí TOÀN DỰ ÁN, Gamma sạch không cứu được cổng.

Đã kiểm application/ vẫn không kéo PySide6 vào sau khi thêm import mới
(tiêu chí C). 714 test xanh, 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:38:44 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 6c68417103 refactor: chia nốt theme.py, i18n.py, usage_tracker.py — Gamma hết file vượt 400 dòng
Ba file dữ liệu cuối cùng của Gamma còn trên ngưỡng CASAN Check 2.

i18n.py  3075 -> 94
    Dict STRINGS 3.000 dòng cắt thành 10 cụm theo đúng mốc phân đoạn có sẵn
    trong file (mỗi mốc là một màn/hộp thoại), cụm nào quá dài thì cắt tiếp ở
    ranh giới khoá. i18n.py giờ chỉ gộp lại và giữ 4 hàm set_language/
    get_language/tr/on_language_changed.

    Kiểm bằng cách so với bản gốc lấy từ git: 1437 mục / 1431 khoá duy nhất
    (bản gốc vốn có 6 khoá lặp), sau khi chia vẫn 1431, KHÔNG thiếu khoá nào,
    KHÔNG thừa khoá nào, KHÔNG giá trị nào lệch. Thứ tự gộp giữ nguyên nên
    quy tắc "khoá trùng thì bản sau thắng" không đổi.

theme.py  907 -> 130
    theme_palettes.py 328  hai bảng màu Tối/Sáng + lớp Palette
    theme_qss.py      198  nửa vỏ (reset + shell)
    theme_qss_controls.py 307  nửa điều khiển (nút, ô nhập, tab, badge)

    Khuôn QSS 470 dòng cắt đôi đúng mốc `/* ---- surfaces */` của chính nó.
    Đã đối chiếu: stylesheet('dark') ra đúng 24762 ký tự y như trước — khớp
    từng byte, không phải "trông có vẻ giống".

core/usage_tracker.py  536 -> 307
    usage_cost.py      101  bảng giá, quy đổi token sang tiền, định dạng
    usage_periods.py   144  gộp theo ngày/tuần/tháng/quý, chuỗi vẽ biểu đồ
    usage_ai_report.py  56  dựng câu nhắc cho AI phân tích

HAI LẦN TỰ CẮT HỎNG, ĐỀU CÙNG MỘT GỐC
--------------------------------------
1. Cắt theo m.lineno mà quên dòng @decorator phía trên -> @dataclass của
   Palette bị bỏ lại mồ côi, "Palette() takes no arguments".
2. Đọc số dòng từ AST GỐC trong khi danh sách dòng đã bị cắt -> lần bóc thứ
   hai dùng toạ độ cũ và cắt vào giữa một chữ ký hàm.

Cả hai lộ ngay vì mỗi script tự parse lại sau khi ghi. Bài học đã áp vào cả
ba lần chia: parse lại sau mỗi lần cắt, và luôn tính cả decorator.

KẾT QUẢ CASAN CHECK 2
---------------------
    Nam       0 file vượt 400   (trước: 4, tổng 5.898 dòng)
    Hiệp      0                 (trước: 1)
    Lâm       0                 (trước: 1)
    file mới  0                 (61 file dưới presentation/ application/
                                 domain/ infrastructure/ — chưa cái nào vượt)

Gamma sạch. 23 file còn vượt đều thuộc team khác (chat_panel.py 1802,
folder_tab.py 1589, structure_graph_view.py 1034...) — cần báo lên sớm chứ
đừng để tới hạn 30/08 mới lộ.

714 test xanh. 24/24 checker qua. CASAN Check 1 sạch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:57:01 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 0e00bf3c2f refactor(co4e): co4e_tab.py 1885 -> 389, Co4ETab tách thành 7 mixin
File to nhất còn lại của Gamma. Lâm bàn giao ở 1.885 dòng với 100 method
trong một lớp; chia theo bảy mối quan tâm:

    co4e_runs.py           364   chạy flow, 3 chế độ, bảng lịch sử lượt chạy
    co4e_chat.py           343   khung chat + đếm token + định tuyến riêng
    co4e_layout.py         308   ba khung, bảng cấu hình, bố cục màn hẹp
    co4e_sidebar.py        251   thư viện workflow/agent/skill, 4 mục gập
    co4e_flow_tabs.py      180   dải tab các flow đang mở
    co4e_workflow_crud.py  154   tạo/sửa/xoá/nhân bản workflow
    co4e_agents.py          51   agent và skill dùng trong flow
    ui/co4e_tab.py         389   __init__, set_project, thư mục output

Mọi file dưới 400 dòng.

MỘT LỖI SUÝT LÀM HỎNG FILE: bản đầu tôi cắt method theo m.lineno, mà lineno
trỏ vào dòng `def`, không tính dòng `@...` phía trên. Decorator bị bỏ lại
thành mồ côi ngay trên một hằng số lớp -> file hỏng cú pháp. Bắt được vì
script tự parse lại sau mỗi lần cắt; nếu chỉ cắt rồi ghi thì đã đẩy lên một
file không import nổi.

Ba vòng sửa mức import tương đối: co4e_tab.py nằm ở ui/ (1 cấp), file mới ở
presentation/co4e/ (2 cấp). Còn co4e_canvas / co4e_config_panel /
co4e_agent_dialog thì VẪN ở ui/, nên `.co4e_canvas` phải thành
`...ui.co4e_canvas` chứ không phải `.co4e_canvas` cùng thư mục.

714 test xanh — trong đó có ~4.000 dòng test đặc tả Lâm viết cho đúng vùng
này, nên việc tách được soi khá kỹ. check_co4e, check_controls_alive,
check_layout_geometry, check_probes_bite đều qua.

Cập nhật đích đột biến thứ ba của check_probes_bite: dải tab flow nay ở
presentation/co4e/co4e_layout.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:43:33 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 70a0c2fdcf refactor(shell): R08-T10 xong — app.py 1293 -> 128, MainWindow tách thành 11 file
Đây là deliverable còn thiếu duy nhất trong 17 task của Gamma.

    app.py                     128   chỉ còn điểm vào chương trình
    presentation/shell/
      main_window.py           362   __init__ + vòng đời cửa sổ
      nav_rail.py              385   dựng rail + cây điều hướng + thu gọn
      top_bar.py               234   thanh trên + tài khoản + đáy rail
      session_events.py        104   lịch sử, thông báo task xong
      page_registry.py          82   4 màn chính, dựng lười, _goto
      rail_project.py          132   bộ chọn project + RECENTS
      lifecycle_coordinator.py 110   canh màn hình + tắt sạch
      tray_manager.py           76   khay hệ thống
      toast.py                  40   thông báo góc trên trái
      bootstrap.py              42   Composition Root
      branding.py               26   ASSETS + app_icon
      rail_metrics.py           37   kích thước rail + cách vẽ hàng

Mọi file dưới 400 dòng. Đây là ngưỡng CASAN Check 2.

NÓI THẲNG VỀ CÁCH TÁCH: sáu file trong đó là MIXIN, không phải widget rời.
Cả loạt phương thức đọc/ghi state của cửa sổ (self._page_widgets, self.workspace,
self.splitter...). Biến thành đối tượng cộng tác thì phải viết lại từng chỗ
self.X thành self.window.X — gần 800 dòng sửa chỉ để đổi cách gọi, rủi ro cao
mà không đổi hành vi. Mixin cho đúng thứ đang cần: mỗi mảng một file, ai sửa
rail thì mở file rail. Chuyển thành widget thật khi có cửa sổ thứ hai cần dùng
lại — hiện chưa có.

Giữ đường vào cũ: MainWindow, app_icon, _NAV_*, _Toast vẫn import được từ
cowork_local.app, nên 24 checker trong tools/ không phải sửa.

BA LỖI TỰ GÂY TRONG LÚC TÁCH, ĐỀU DO CHECKER BẮT
-------------------------------------------------
1. 12 import lazy nằm trong thân hàm bị thụt lề nên regex đổi mức tương đối
   của tôi bỏ sót -> ModuleNotFoundError khi bấm vào rail.
2. Bộ dò import thiếu của tôi tính cả import cục bộ trong hàm KHÁC, nên tưởng
   QHBoxLayout đã có -> 17 checker đỏ. Bỏ cách dò, cấp thẳng khối import đầy
   đủ rồi cắt phần không dùng.
3. Hằng số ASSETS và _NAV_* nằm ở khối tôi không mang theo -> NameError.

Cả ba đều là lỗi im lặng với bộ test đơn vị (714 vẫn xanh suốt) và chỉ lộ khi
dựng cửa sổ thật. Đó chính là lý do bộ checker trong tools/ tồn tại.

Cập nhật 2 đích đột biến của check_probes_bite: mã nó cần sửa đã dời khỏi
app.py sang rail_project.py và nav_rail.py.

714 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:32:22 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 bc282c71d0 refactor(config): AppConfig thành vỏ mỏng trên repository + vá 3 chỗ gán im lặng hỏng
config.py 623 -> 377 dòng (qua ngưỡng 400 của CASAN Check 2).

Class AppConfig 278 dòng giờ còn 30: mọi lối vào dẫn tới JsonConfigRepository.
Không xoá hẳn vì cái tên còn nằm ở 41 file — 23 checker trong tools/ và 18 file
test, trong đó có test của cả ba người. Sửa 41 chỗ trong một commit là đổi thứ
không cần đổi và làm review không đọc nổi. Giữ tên, đổi ruột.

Thêm JsonConfigRepository.from_data() cho dạng AppConfig(data=..., path=...) mà
13 file test đang dùng: dựng thẳng từ dict, không đọc đĩa, không chạy migration
trên dữ liệu test.

MỘT LỖI TÔI GÂY RA HÔM 25/08, HÔM NAY MỚI LỘ
---------------------------------------------
Lúc tráo R02 tôi có đối chiếu API và kết luận "đủ 34/34 thành viên, thay được".
Đối chiếu đó chỉ so TÊN, không so việc một property có setter hay không.

AppConfig cũ là dataclass nên `config.language = "vi"` chạy bình thường.
Repository để language là property chỉ đọc -> gán vào là AttributeError. Ba chỗ
trong app.py đang gán: đổi ngôn ngữ, đổi giao diện, đổi provider trên thanh bên.

Khó thấy vì cả ba nằm trong slot của Qt, mà Qt NUỐT ngoại lệ trong slot. Không
traceback, không thông báo — bấm đổi ngôn ngữ thì không có gì xảy ra. 709 test
đơn vị vẫn xanh suốt. Chỉ check_nav bắt được vì nó bấm thật vào combo rồi kiểm.

Thêm setter cho theme/language/active_provider, và tests/test_config_gan_duoc.py
đi ngược từ mã nguồn: quét cả repo tìm mọi chỗ `config.X = ...` rồi thử gán
thật. Đã kiểm ngược — bỏ setter đi thì 2 bài đỏ.

BẮC CẦU CHO 55 CONTROL MONITORING
----------------------------------
check_controls_alive so với mốc git 291a611 và đòi 55 control ov_* của Tổng
quan phải còn tới được. Sau khi Hiệp tách 8 tab, chúng về đúng tab/thẻ của mình
và rụng tiền tố -> 3 checker đỏ.

Control còn đủ, chỉ đổi chỗ ở. Bắc cầu bằng __getattr__ định tuyến theo tiền tố
(ov_perm_ -> permissions_card, ov_sbx_ -> sandbox_card, ov_price_/ov_pricing_ ->
pricing_panel, còn lại -> overview_tab), cộng 3 hộp nhóm mà bản thân widget con
chính là hộp đó.

Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card đều
có network_lbl — một cái là mức dùng mạng, một cái là quyền truy cập mạng. Bản
dò mờ đầu tiên tôi viết vớ nhầm cái đầu tiên tìm thấy.

714 test xanh. 24/24 checker qua (3 cái đã đỏ từ trước khi tôi bắt đầu, do phần
monitoring, nay xanh lại). CASAN Check 1 sạch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:03:28 +09:00
Hiep Ha Van 72ed3b4147 Merge remote-tracking branch 'origin/gamma/refactor' 2026-08-25 23:55:46 +09:00
Hiep Ha VanandClaude Sonnet 5 40b12ecb15 refactor(monitoring): N2 - tach monitoring_tab.py, CanonicalAuditLogger, MonitoringQueryService, go circular import, sandbox matrix
- ui/monitoring_tab.py (1546 dong) tach thanh presentation/monitoring/**
  (container + 7 tab/card + shared helper), ui/monitoring_tab.py con lai
  re-export shim de app.py khong doi.
- infrastructure/telemetry/audit_logger.py: CanonicalAuditLogger, core/audit_log.py
  thanh wrapper mong, tuong thich nguoc 100% voi schema .jsonl cu.
- application/monitoring/monitoring_query_service.py: MonitoringQueryService
  read-only, filter/sort/pagination, khong import PySide6.
- Go circular import model_pricing<->usage_tracker va agent_security<->
  agent_security_alert (core/agent_security_types.py moi).
- infrastructure/sandbox/sandbox_capabilities.py: SandboxCapabilityMatrix
  theo OS (Windows/Linux/macOS), chua dau noi vao core/sandbox_manager.py.
- conftest.py: sua loi checkout khong ten cowork_local khien pytest import
  nham thu muc khac.
- 77 test moi, 167/167 pass. QA da xac nhan UI/business logic khong doi
  (xem evidence/report/unified_report.html).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:52:36 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 4c3b097977 refactor(ui): xoá 108 dòng MS365 chết trong settings_dialog.py
Sót lại từ lần dời UI Connector sang Monitoring → Tools → Connector. Năm hàm:

    _refresh_ms365_status    13    _show_ms365_device_code   52
    _ms365_sign_in           34    _ms365_sign_out            4
    _close_ms365_code_dialog  5

Chứng minh chết trước khi xoá, không xoá theo cảm tính:

* Dựng đồ thị lời gọi bằng ast: **mọi** lời gọi tới năm hàm này đều xuất phát
  từ bên trong chính năm hàm đó. Không một đường vào nào từ ngoài cụm — cả
  trong file lẫn toàn repo.
* Ba thuộc tính chúng đọc — ms365_status, ms365_signin_btn, ms365_signout_btn
  — **chưa từng được gán ở đâu**. Gọi vào là AttributeError, không phải chạy sai
  mà là sập.
* _ms365_workers chỉ được append bên trong _ms365_sign_in, nên chết theo.

Dọn kèm 6 import chỉ còn dòng import: AgentWorker, icon, EXT_CATEGORIES,
ExtConnectorEditDialog, AppContext, QTreeWidget.

Viết lại docstring đầu file — bản cũ vẫn mô tả file này chứa nhóm Connector
(CAD/CAE/MS365/Other), thứ đã không còn ở đây từ lâu.

settings_dialog.py: 407 -> 303 dòng. Cộng cả R08-T07 thì từ 727 xuống 303.

632 test xanh. check_dialogs, check_no_hscroll, check_design_parity,
check_orphans, check_probes_bite đều qua.

Ghi lại một phát hiện phụ, CHƯA xử lý: i18n.py có 28 khoá settings.ms365_*
mồ côi — 20 khoá đã không ai dùng từ trước lần dời connector, 8 khoá vừa mồ
côi theo commit này. Chỉ 2 khoá còn sống (ms365_local_connected,
ms365_local_none, dùng ở ui/connectors_panel.py). Xoá khoá dịch là đụng vào
dữ liệu ba ngôn ngữ ở file khác nên để anh Nam quyết riêng.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 21:15:15 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 c77ce36191 refactor(shell): R08-T10 — bootstrap + tách TrayManager và LifecycleCoordinator
app.py 1356 -> 1293 dòng. presentation/shell/ có 3 file:

  bootstrap.py               Composition Root (đã vào ở commit trước)
  tray_manager.py            khay hệ thống + thông báo bong bóng
  lifecycle_coordinator.py   canh cửa sổ theo màn hình + tắt cho sạch

Vì sao tách khay: khay là thứ CÓ THỂ KHÔNG TỒN TẠI (một số môi trường Linux,
phiên RDP). Trước đây mỗi chỗ dùng phải tự nhớ kiểm `if self.tray is not None`
— có 6 chỗ như thế, và 3 chỗ còn phải tự bọc try/except quanh showMessage.
Gói lại thì chỗ gọi cứ gọi, không có khay thì không có gì xảy ra.

Vì sao tách vòng đời: hai việc trong đó không phải việc của giao diện. Canh
cửa sổ theo màn hình là số học thuần (anh Nam có hai màn khác độ phân giải và
khác tỉ lệ phóng — kéo qua lại là vùng làm việc đổi). Còn shutdown là thứ tự
dừng có ý nghĩa: bộ lập lịch trước để nó không kịp khởi động việc mới trong
lúc ta đang dừng việc cũ, rồi mới tới worker, rồi ngắt tiến trình MCP.

closeEvent/moveEvent/resizeEvent vẫn ở lớp cửa sổ vì Qt gọi thẳng vào đó,
nhưng phần quyết định đã chuyển đi. closeEvent từ 30 dòng còn 11.

Giữ self.tray thành property trỏ vào self._tray.icon — vài chỗ còn đọc tên cũ.

Đã lấy mốc trước khi bóc rồi so lại sau: 24/24 checker trong tools/ qua cả hai
lần. Đây là bộ đặc tả thật cho MainWindow (check_nav, check_rail_align,
check_layout_geometry, check_controls_alive... dựng cửa sổ thật offscreen trên
BẢN SAO của ~/.cowork_local, scheduler bị vô hiệu hoá). 632 test xanh.

CHƯA làm hết R08-T10: plan ghi tách thành main_window.py + tray_manager.py +
lifecycle_coordinator.py. Hai file sau đã xong, main_window.py thì chưa —
MainWindow vẫn nằm trong app.py và vẫn 1095 dòng. Đo lại thì khối lượng không
nằm ở ba cụm plan nêu mà ở hai cụm khác:

    nav rail    18 method, ~340 dòng
    topbar      8 method,  ~157 dòng
    __init__    279 dòng

Hai cụm đó dính chặt vào state của cửa sổ, chuyển đi cần đổi giao diện giữa
chúng chứ không phải dời chỗ, nên tôi dừng ở đây thay vì làm nửa vời.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:56:37 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 2246d55286 feat(infra): R02 vào thật — app chạy bằng JsonConfigRepository, khoá rời khỏi config.json
Từ 21/08 tôi đã viết xong 7 file R02 với 46 test xanh, và báo là "xong R02".
Báo sai: code mới nằm song song, KHÔNG một dòng nào ngoài infrastructure/ và
tests/ gọi tới nó. App vẫn chạy nguyên trên config.py, 29 file dùng nó, và
khoá API của người dùng vẫn nằm plaintext trong config.json suốt 4 ngày.

Commit này mới là phần refactor thật.

Bù 21 thành viên còn thiếu (85 dòng)
------------------------------------
JsonConfigRepository có 18/34 thành viên công khai của AppConfig nên không
tráo được. Chép nguyên ngữ nghĩa 21 cái còn lại: load, ms365_*, ext_connectors,
connect_external, routing_mode_for, seeded_*, mcp_servers, teams, history,
structure, monitoring_visibility, model_label, ca_bundle... Giờ 40/34, không
thiếu gì. Không phải thiết kế mới — chừng nào 29 file còn gọi qua ctx.config
thì repository phải trả lời được đúng các câu hỏi cũ.

ROUTING_MODES lấy theo bản Delta (4 chế độ, có "fallback" từ R03-T03) chứ
không theo bản main cũ 3 chế độ. Chép bản cũ là routing "fallback" âm thầm rơi
về "off" sau khi Delta merge, không lỗi nào báo.

Composition Root (R08-T10, phần đầu)
-------------------------------------
presentation/shell/bootstrap.py: một chỗ duy nhất quyết định app dựng bằng
mảnh nào. app.py::run giờ gọi build_context() thay cho AppConfig.load().
Đây cũng là chỗ ráp kho bí mật vào; máy không có keyring thì secrets=None và
mọi thứ chạy như cũ.

Kiểm trên dữ liệu thật
----------------------
Chạy lên máy tôi, migration tự chạy đúng như thiết kế:

  openai_compat  39 ký tự  config.json -> Windows Credential Manager
  ollama         giá trị bù nhìn, để nguyên trong file, không đẩy vào kho
  schema_version 1 -> 2
  sao lưu        config.json.v20260825-193206.bak

Sau khi bật lại app và để nó ghi cấu hình, config.json vẫn sạch: api_key rỗng,
không còn chuỗi nào có hình dạng khoá. scripts/audit_security.py sạch.

Tiêu chí nghiệm thu A của plan (dòng 244) — "0 lưu trữ plaintext API Key trong
JSON" — tới commit này mới thật sự đạt.

632 test xanh. check_dialogs, check_nav, check_design_parity đều qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:35:44 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 e4ce9b2f5f merge: lấy phần N3 của Lâm (6 widget UI Co4E) về nhánh chung
Không xung đột — Lâm động vào ui/co4e_tab.py và presentation/co4e/,
tôi động vào ui/settings_dialog.py và presentation/settings/. Đúng như
quy tắc phân chia sở hữu đặt ra từ đầu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:23:20 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 2b90492994 refactor(ui): R08-T07 — bóc settings_dialog.py 727 → 407 dòng thành 4 widget
Bốn mục trong Cài đặt tách thành widget riêng dưới presentation/settings/:

    general_settings_widget.py     ngôn ngữ, giao diện, khay, gợi ý
    provider_settings_widget.py    provider, base URL, key, model + 2 nút nền
    parameter_settings_widget.py   đính kèm, cấu trúc, giới hạn sandbox
    routing_settings_widget.py     Auto Model Routing

Mỗi widget tự dựng control, tự nạp giá trị, tự có apply_to(data). Dialog chỉ
còn lắp ráp và gọi apply_to lúc lưu — _save từ 34 dòng xuống còn phần khung.

Làm lưới an toàn trước khi bóc: tests/ui/test_settings_dialog_dac_ta.py, 7
bài đặc tả hành vi hiện tại (mục nào có mặt, nạp đúng giá trị gì, lưu ghi vào
đúng ô nào, đổi % sang phân lẻ, xoá cache sau lưu). Bóc xong cả 7 vẫn xanh,
và trong lúc bóc chúng đã đỏ đúng hai lần ở chỗ đáng đỏ.

Đây là repo chưa từng có test Qt nào — thêm tests/ui/conftest.py dựng
QApplication offscreen. Offscreen là bắt buộc chứ không phải cho nhanh: máy
dev là máy làm việc thật, test bật cửa sổ lên là nó nhảy ra che màn hình.

Dọn kèm:
* bỏ vòng "dựng vào layout rồi lại gỡ ra" của mục Chung, cùng widget cao 0px
  làm mốc cuộn — không cần nữa khi mục đó tự là một widget
* bỏ _select_combo, _secret, _model_combo, _with_load và 4 hàm provider khác
  đã chuyển vào widget (127 dòng)
* bỏ 5 import chết theo (Dict, QSizePolicy, PROVIDER_LABELS, SegmentedControl,
  LANGUAGES)

Giữ cầu tương thích: self.routing_*, self.prov_*, self.attach_* … thành
property trỏ vào widget con, vì 5 checker trong tools/ đọc thẳng tên cũ. Bỏ
được khi tools/ chuyển sang đọc self._provider_page.

Hai điều KHÔNG làm, ghi lại để khỏi tưởng là quên:
1. Plan ghi 4 widget và có tên `connector`. Thực tế UI connector đã dời khỏi
   Cài đặt từ trước (ghi chú ở settings_dialog.py:180 bản cũ), nên số mục thật
   là 5, không phải 4, và không có mục nào tên connector. Bốn mục bóc ra là 4
   mục có thật; mục Bảo mật sandbox để nguyên trong dialog lần này.
2. Còn ~108 dòng chết của MS365 (_refresh_ms365_status, _ms365_sign_in,
   _show_ms365_device_code, _ms365_sign_out): đọc self.ms365_status,
   self.ms365_signin_btn, self.ms365_signout_btn — ba thuộc tính KHÔNG BAO GIỜ
   được gán, và không hàm nào có người gọi. Gọi vào là AttributeError. Chưa
   xoá vì đó là quyết định của anh Nam, không phải việc kèm theo của T07.

437 test xanh. check_dialogs, check_no_hscroll, check_design_parity đều qua.

Kèm docs/refactor/tin-gui-team-hoa.md — tin báo Hoa về platform/ -> adapters/
và bản vá Windows của AtomicJsonFile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:22:54 +09:00
102 changed files with 12429 additions and 9332 deletions
+19 -1243
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
"""Application-layer view of an audit event — decoupled from the
infrastructure ``CanonicalAuditEvent`` so ``application/`` doesn't need to
share a concrete class with ``infrastructure/`` (only the shape). Field names
match the canonical audit schema (see
``infrastructure/telemetry/audit_logger.py``) 1:1.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict
@dataclass(frozen=True)
class AuditEventDTO:
ts: str
kind: str
name: str
ok: bool
detail: str
agent_role: str = ""
account: str = ""
role: str = ""
machine: str = ""
@classmethod
def from_raw(cls, raw: Dict[str, Any]) -> "AuditEventDTO":
"""Tolerant of missing keys — accepts both a
``CanonicalAuditEvent.to_dict()`` result and any historical raw
``.jsonl`` row."""
return cls(
ts=str(raw.get("ts", "")),
kind=str(raw.get("kind", "")),
name=str(raw.get("name", "")),
ok=bool(raw.get("ok", False)),
detail=str(raw.get("detail", "")),
agent_role=str(raw.get("agent_role", "")),
account=str(raw.get("account", "")),
role=str(raw.get("role", "")),
machine=str(raw.get("machine", "")),
)
def to_dict(self) -> Dict[str, Any]:
return {
"ts": self.ts, "kind": self.kind, "agent_role": self.agent_role,
"name": self.name, "ok": self.ok, "detail": self.detail,
"account": self.account, "role": self.role, "machine": self.machine,
}
@@ -0,0 +1,54 @@
"""Read-only query service over audit events — filter + sort + pagination.
Pure Python: no PySide6 import, no UI code. Depends only on an injected
``AuditEventRepository`` (see ``repository/audit_event_repository.py``), so it
is fully unit-testable with ``InMemoryAuditEventRepository`` and independent
of file I/O or Qt.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from .dto.audit_event_dto import AuditEventDTO
from .repository.audit_event_repository import AuditEventRepository
@dataclass(frozen=True)
class Page:
items: List[AuditEventDTO]
total: int
page: int
page_size: int
@property
def has_more(self) -> bool:
return self.page * self.page_size < self.total
class MonitoringQueryService:
"""Read-only. Callers ask for a filtered/sorted/paginated slice of the
audit log; this service never writes anything."""
def __init__(self, repository: AuditEventRepository) -> None:
self._repository = repository
def query(self, kind: Optional[str] = None, ok: Optional[bool] = None,
text: Optional[str] = None, sort_by: str = "ts",
descending: bool = True, page: int = 1, page_size: int = 50) -> Page:
events = self._repository.load(kind=kind)
if ok is not None:
events = [e for e in events if e.ok == ok]
if text:
needle = text.lower()
events = [e for e in events
if needle in e.name.lower() or needle in e.detail.lower()]
events = sorted(events, key=lambda e: getattr(e, sort_by, ""), reverse=descending)
total = len(events)
page = max(1, page)
start = (page - 1) * page_size
items = events[start:start + page_size] if page_size > 0 else events
return Page(items=items, total=total, page=page, page_size=page_size)
@@ -0,0 +1,41 @@
"""Audit-event repository — the boundary between ``MonitoringQueryService``
and where events actually live. ``CanonicalAuditEventRepository`` is the real
adapter (wraps an injected ``CanonicalAuditLogger``); ``InMemoryAuditEventRepository``
is a constructor-injected test double, following this repo's existing
``Fake*``/``Recording*`` convention (see ``tests/routing/*``,
``tests/test_project_context_mcp_template.py``) rather than ``unittest.mock``.
"""
from __future__ import annotations
from typing import List, Optional, Protocol
from ..dto.audit_event_dto import AuditEventDTO
class AuditEventRepository(Protocol):
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
...
class CanonicalAuditEventRepository:
"""Adapter over ``infrastructure.telemetry.audit_logger.CanonicalAuditLogger``
— the only place this application service reaches into infrastructure."""
def __init__(self, audit_logger) -> None:
self._audit_logger = audit_logger
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
events = self._audit_logger.load_events(kind=kind)
return [AuditEventDTO.from_raw(e.to_dict()) for e in events]
class InMemoryAuditEventRepository:
"""Test double — holds a fixed list of events, no file I/O."""
def __init__(self, events: List[AuditEventDTO]) -> None:
self._events = list(events)
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
if kind is None:
return list(self._events)
return [e for e in self._events if e.kind == kind]
@@ -35,6 +35,8 @@ service này.
"""
from __future__ import annotations
from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
import os
from datetime import datetime
@@ -174,10 +176,10 @@ class Co4EWorkflowService:
payload = {"runs": [r.to_dict() for r in runs]}
try:
self._history_path_value.parent.mkdir(parents=True, exist_ok=True)
tmp = self._history_path_value.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8")
tmp.replace(self._history_path_value) # atomic — khong bao gio de lai file ghi do dang
# AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync
# (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng
# Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows.
AtomicJsonFile(self._history_path_value).write(payload)
except OSError:
# Giu dung hanh vi cu (core/co4e_run_manager.py::_save_history):
# mot lan luu that bai (day dia, mat quyen...) KHONG duoc phep
+29 -275
View File
@@ -16,6 +16,8 @@ import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from .infrastructure.config.json_config_repository import JsonConfigRepository
from typing import Any, Dict, List
CONFIG_DIR = Path.home() / ".cowork_local"
@@ -343,281 +345,33 @@ def _migrate_connectors(data: Dict[str, Any]) -> None:
data["mcp_servers"] = [] # migrated — the UI no longer manages this
@dataclass
class AppConfig:
"""In-memory view of the configuration with load/save helpers."""
class AppConfig(JsonConfigRepository):
"""Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`.
data: Dict[str, Any] = field(default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG))
path: Path = CONFIG_PATH
Ngày 25/08 app chuyển hẳn sang repository (ghi nguyên tử, khoá nằm trong
kho bí mật của hệ điều hành). Nhưng cái tên ``AppConfig`` còn nằm ở 41 file
— 23 checker trong ``tools/`` và 18 file test, trong đó có test của cả ba
người. Sửa hết 41 chỗ trong một commit là đổi thứ không cần đổi và làm
review không đọc nổi.
Nên giữ tên, đổi ruột: mọi lối vào đều dẫn tới repository.
Bỏ hẳn được khi ``tools/`` và ``tests/`` chuyển sang gọi
``presentation.shell.bootstrap.build_context()``.
"""
def __init__(self, data=None, path: Path = CONFIG_PATH, **kw):
if data is None:
super().__init__(Path(path), **kw)
return
# Dạng AppConfig(data=..., path=...) mà 13 file test đang dùng: dựng
# thẳng từ dict, không đụng đĩa.
built = JsonConfigRepository.from_data(data, Path(path))
self.__dict__.update(built.__dict__)
# ---- persistence -------------------------------------------------
@classmethod
def load(cls, path: Path = CONFIG_PATH) -> "AppConfig":
merged = copy.deepcopy(DEFAULT_CONFIG)
if path.exists():
try:
stored = json.loads(path.read_text(encoding="utf-8"))
merged = _deep_merge(merged, stored)
except (json.JSONDecodeError, OSError):
# Corrupt config should never block startup.
merged = copy.deepcopy(DEFAULT_CONFIG)
merged = _apply_env_overrides(merged)
# "unlocked" is a runtime-only Settings-panel state (see the "ms365"
# comment in DEFAULT_CONFIG) — never trust a stored/hand-edited value,
# every launch starts locked.
merged.setdefault("ms365", {})["unlocked"] = False
_migrate_connectors(merged) # office→ms365 + legacy mcp_servers→other
return cls(data=merged, path=path)
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
to_write = self.data
if self.data.get("ms365", {}).get("unlocked"):
# Defense in depth: even if some caller saves without having gone
# through the Settings dialog's own auto-lock-after-save flow, the
# unlock state must never reach disk.
to_write = copy.deepcopy(self.data)
to_write["ms365"]["unlocked"] = False
self.path.write_text(
json.dumps(to_write, indent=2, ensure_ascii=False), encoding="utf-8"
)
# ---- convenience accessors --------------------------------------
@property
def active_provider(self) -> str:
# Migrate configs that still point at a removed provider (e.g. an older
# install saved "ollama") to a supported one, so the app never tries to
# build an unknown provider.
val = self.data.get("active_provider", "openai_compat")
return val if val in PROVIDER_LABELS else "openai_compat"
@active_provider.setter
def active_provider(self, value: str) -> None:
self.data["active_provider"] = value
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
name = name or self.active_provider
return self.data["providers"].get(name, {})
@property
def ca_bundle(self) -> str:
"""Path to a custom CA/certificate PEM file, or '' for normal validation.
Used as ``requests``' ``verify=`` argument for every outbound HTTPS call
— see the "tls_ca_bundle" comment above for when this is needed."""
return (self.data.get("tls_ca_bundle") or "").strip()
@ca_bundle.setter
def ca_bundle(self, value: str) -> None:
self.data["tls_ca_bundle"] = (value or "").strip()
# ---- Microsoft 365 connections (Settings-panel lock, see DEFAULT_CONFIG) --
@property
def ms365(self) -> Dict[str, Any]:
return self.data.setdefault("ms365", copy.deepcopy(DEFAULT_CONFIG["ms365"]))
# ---- Login / RBAC / shared cross-machine store (see DEFAULT_CONFIG) ------
@property
def auth(self) -> Dict[str, Any]:
return self.data.setdefault("auth", copy.deepcopy(DEFAULT_CONFIG["auth"]))
@property
def shared_dir(self) -> str:
return (self.auth.get("shared_dir") or "").strip()
def ms365_try_unlock(self, code: str) -> bool:
"""Unlock the MS365 Settings group for this session if ``code`` matches.
This is a client-side UI lock (prevents casually toggling a sensitive
section), NOT Microsoft authentication — see the DEFAULT_CONFIG
comment. Never persisted as unlocked; see ``save()``."""
if (code or "") and code == self.ms365.get("unlock_code", ""):
self.data["ms365"]["unlocked"] = True
return True
return False
def ms365_lock(self) -> None:
self.data.setdefault("ms365", {})["unlocked"] = False
@property
def theme(self) -> str:
return self.data.get("theme", "dark")
@theme.setter
def theme(self, value: str) -> None:
self.data["theme"] = value
@property
def language(self) -> str:
from .i18n import DEFAULT_LANGUAGE, LANGUAGES
val = self.data.get("language", DEFAULT_LANGUAGE)
return val if val in LANGUAGES else DEFAULT_LANGUAGE
@language.setter
def language(self, value: str) -> None:
self.data["language"] = value
@property
def code(self) -> Dict[str, Any]:
return self.data["code"]
@property
def tools_disabled(self) -> list:
"""Built-in agent tool names the admin has turned off (Monitoring → Tools)."""
return self.data.setdefault("tools", {}).setdefault("disabled", [])
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Enable/disable a built-in agent tool by name and persist it."""
disabled = set(self.tools_disabled)
if enabled:
disabled.discard(name)
else:
disabled.add(name)
self.data.setdefault("tools", {})["disabled"] = sorted(disabled)
self.save()
@property
def connect_external(self) -> bool:
"""Master switch (Monitoring → Tools → Connector): when off, the agent
connects to NO external connectors (CAD/CAE/MS365/Other MCP + REST).
Defaults ON so existing setups keep working."""
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
def set_connect_external(self, enabled: bool) -> None:
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
self.save()
# ---- one-time seeding bookkeeping (built-in skill library / flows) -------
@property
def seeded_library_skills(self) -> List[str]:
"""Slugs of bundled library skills already seeded into the user's Skill
Manager — so a user-deleted one is never silently re-seeded."""
return list(self.data.setdefault("seeded_library_skills", []))
@seeded_library_skills.setter
def seeded_library_skills(self, slugs) -> None:
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
@property
def seeded_builtin_flows(self) -> List[str]:
"""Ids of built-in Co4E flows already seeded (same respect-user-deletion
rule as seeded_library_skills)."""
return list(self.data.setdefault("seeded_builtin_flows", []))
@seeded_builtin_flows.setter
def seeded_builtin_flows(self, ids) -> None:
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
@property
def teams(self) -> Dict[str, Any]:
return self.data["teams"]
@property
def history(self) -> Dict[str, Any]:
return self.data["history"]
@property
def codebase_memory(self) -> Dict[str, Any]:
return self.data["codebase_memory"]
@property
def agent_security(self) -> Dict[str, Any]:
return self.data["agent_security"]
@property
def mcp_servers(self) -> List[Dict[str, Any]]:
return self.data.setdefault("mcp_servers", [])
@property
def ext_connectors(self) -> Dict[str, List[Dict[str, Any]]]:
"""Unified Connectors (MCP), grouped by category CAD/CAE/MS365/Other —
see core/ext_connectors.py for the per-entry shape and CATEGORIES."""
d = self.data.setdefault("ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
@property
def cowork(self) -> Dict[str, Any]:
return self.data["cowork"]
@property
def routing(self) -> Dict[str, Any]:
"""Auto Model Assessment & Routing behaviour config (see DEFAULT_CONFIG).
Always returns a dict with every expected key present, backfilling any
missing sub-keys from the defaults so older configs upgrade seamlessly."""
d = self.data.setdefault("routing", copy.deepcopy(DEFAULT_CONFIG["routing"]))
for k, v in DEFAULT_CONFIG["routing"].items():
d.setdefault(k, copy.deepcopy(v))
d.setdefault("surface_modes", {})
for surface in ("cowork", "co4e", "ai_edit"):
d["surface_modes"].setdefault(surface, "")
return d
# The routing modes a surface may be in. "fallback" joined the set in
# R03-T03 (keep the selected model; re-route only when it cannot serve the
# turn) — see application/model_routing/routing_models.py::RoutingMode,
# which is the authority on what each mode means.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
A per-surface override wins; an empty override falls back to the global
``switch_mode``. Anything unrecognised degrades to "off" so routing
stays opt-in even with a hand-edited config."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's routing toggle selection."""
mode = mode if mode in self.ROUTING_MODES else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
@property
def structure(self) -> Dict[str, Any]:
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
@property
def monitoring_visibility(self) -> Dict[str, bool]:
return self.data.setdefault(
"monitoring_visibility", copy.deepcopy(DEFAULT_CONFIG["monitoring_visibility"]))
def cowork_output_dir(self) -> Path:
"""Where Cowork saves generated files (OneDrive folder by default)."""
custom = (self.cowork.get("output_dir") or "").strip()
if custom:
return Path(custom).expanduser()
from . import paths # local import avoids any import cycle
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "output"
return CONFIG_DIR / "output" / "cowork"
def history_dir(self) -> Path:
"""Resolve where conversation history is stored.
When a project is open, its history is stored INSIDE the project's
workspace folder (``_project_history_dir``, set by the Workspace screen)
so that sharing/syncing that folder shares the history — another machine
opening the same folder sees the conversations and can continue them.
Otherwise: Local (default) or OneDrive."""
rt = getattr(self, "_project_history_dir", None)
if rt:
return Path(rt)
custom = (self.history.get("custom_dir") or "").strip()
if custom:
return Path(custom).expanduser()
if self.history.get("location") == "onedrive":
from . import paths # local import avoids any import cycle
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "history"
return HISTORY_DIR
def model_label(self) -> str:
return str(self.provider_conf().get("model", "?"))
def load(cls, path: Path = CONFIG_PATH) -> "JsonConfigRepository":
"""Điểm vào cũ. Giờ đi qua Composition Root nên checker và app dùng
chung một đường dựng — kể cả phần ráp kho bí mật."""
from .presentation.shell.bootstrap import build_config
return build_config(Path(path))
+32
View File
@@ -0,0 +1,32 @@
"""Root pytest conftest — loaded before ``tests/conftest.py``.
This checkout lives on disk as ``Refactor`` (not ``cowork_local``), while
``tests/`` imports everything as ``from cowork_local... import ...`` and
``tests/conftest.py`` makes that resolve by putting this repo's *parent*
directory on ``sys.path`` (expecting the repo root itself to be named
``cowork_local``). A sibling folder literally named ``cowork_local`` (an
unrelated, older checkout) already exists next to this one, so without this
file Python would silently import THAT folder instead of this repository
whenever a test does ``import cowork_local``.
Registering the alias here — before ``tests/conftest.py`` touches
``sys.path`` — caches this repository in ``sys.modules['cowork_local']``
first, so the later ``sys.path`` mutation has nothing left to do (imports
are cached by name; the first successful import of a given name wins for
the rest of the process).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parent
if "cowork_local" not in sys.modules:
spec = importlib.util.spec_from_file_location(
"cowork_local", _ROOT / "__init__.py", submodule_search_locations=[str(_ROOT)],
)
module = importlib.util.module_from_spec(spec)
sys.modules["cowork_local"] = module
spec.loader.exec_module(module)
+2 -19
View File
@@ -27,27 +27,12 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import List, Optional
from ..providers.base import Provider
from . import security_rules
class SecurityBlocked(RuntimeError):
"""A guardrail refused an action. ``verdict`` carries the full detail for
the admin alert; ``str(exc)`` is the short, user-facing reason."""
def __init__(self, verdict: "SecurityVerdict"):
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
self.verdict = verdict
@dataclass
class SecurityVerdict:
allowed: bool
reason: str = ""
layer: str = "" # "prompt" | "attachment" | "command"
from .agent_security_alert import notify_admin
from .agent_security_types import SecurityBlocked, SecurityVerdict
def combined_rules_text(config, max_chars: int = 8000, agent_kind: str = "cowork") -> str:
@@ -236,7 +221,6 @@ def enforce_prompt(provider: Provider, messages: List[dict], config, emit,
emit({"type": "notice", "level": "warning",
"text": f"🛡 Yêu cầu bị chặn bởi Agent Security: {verdict.reason}"})
from . import audit_log
from .agent_security_alert import notify_admin
audit_log.record("security_block", "prompt", False, verdict.reason)
notify_admin(config, verdict, detail=user_text[:1000])
@@ -266,7 +250,6 @@ def enforce_command(provider: Provider, name: str, args: dict, config, emit,
emit({"type": "notice", "level": "warning",
"text": f"🛡 Lệnh bị chặn bởi Agent Security ({verdict.layer}): {verdict.reason}"})
from . import audit_log
from .agent_security_alert import notify_admin
audit_log.record("security_block", name, False, f"{verdict.layer}: {verdict.reason}")
notify_admin(config, verdict, detail=command)
+1 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from typing import Tuple
from . import ms365_graph
from .agent_security import SecurityVerdict
from .agent_security_types import SecurityVerdict
from .ms365_auth import Ms365AuthError, get_access_token
+33
View File
@@ -0,0 +1,33 @@
"""Shared value types for the Agent Security guardrails.
``SecurityVerdict``/``SecurityBlocked`` used to be defined in
``agent_security.py``, which forced ``agent_security_alert.py`` (which only
needs the *type*, to annotate/read ``notify_admin``'s ``verdict`` argument) to
import from it — while ``agent_security.py`` itself needed to call
``agent_security_alert.notify_admin()``, an architectural cycle only avoided
at runtime by deferring that second import inside a function body.
Hoisting the shared type into this dependency-free leaf module lets both
sides import it directly, so ``agent_security.py`` can import
``agent_security_alert`` at module top level too — no cycle, no deferred
imports needed for this pair.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class SecurityVerdict:
allowed: bool
reason: str = ""
layer: str = "" # "prompt" | "attachment" | "command"
class SecurityBlocked(RuntimeError):
"""A guardrail refused an action. ``verdict`` carries the full detail for
the admin alert; ``str(exc)`` is the short, user-facing reason."""
def __init__(self, verdict: SecurityVerdict):
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
self.verdict = verdict
+15 -72
View File
@@ -6,15 +6,23 @@ storage systems).
One JSON line per event, one file per day under ``~/.cowork_local/audit/`` —
same on-disk shape as ``usage_tracker.py`` (day-sharded ``.jsonl``, append-only,
``record()`` never raises so audit logging can never break a chat turn).
This module is now a thin, backward-compatible wrapper around
:class:`infrastructure.telemetry.audit_logger.CanonicalAuditLogger` — every
existing call site (``agent_security.py``, ``chat_agent.py``, ``tools.py``,
``ext_connectors.py``, ``mcp_client.py``, ``ms365_local.py``,
``permissions.py``, ``ui/structure_graph_view.py``, ``app.py``) keeps calling
``audit_log.set_identity``/``record``/``load_events`` exactly as before; only
the implementation moved.
"""
from __future__ import annotations
import json
from datetime import date, datetime
from datetime import date
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from ..infrastructure.telemetry.audit_logger import CanonicalAuditLogger
AUDIT_DIR = CONFIG_DIR / "audit"
@@ -23,66 +31,21 @@ AUDIT_DIR = CONFIG_DIR / "audit"
# action), "mcp_call" (a call to an external MCP server's tool).
Kind = str
# Process-global identity — who's logged in, their role, and this machine's
# name — set once right after login (app.py::run()), mirroring
# usage_tracker.py's identical pattern. NOT thread-local: fixed per process.
_identity_account = ""
_identity_role = ""
_identity_machine = ""
_identity_shared_dir = ""
_logger = CanonicalAuditLogger(AUDIT_DIR)
def set_identity(account: str, machine: str, role: str = "", shared_dir: str = "") -> None:
"""Called once after login succeeds. ``shared_dir``, when reachable,
makes every subsequent :func:`record` ALSO best-effort-append to the
shared cross-machine telemetry store (see :mod:`telemetry_shared`)."""
global _identity_account, _identity_role, _identity_machine, _identity_shared_dir
_identity_account = account or ""
_identity_role = role or ""
_identity_machine = machine or ""
_identity_shared_dir = shared_dir or ""
_logger.set_identity(account, machine, role=role, shared_dir=shared_dir)
def record(kind: Kind, name: str, ok: bool, detail: str = "",
agent_role: str = "") -> None:
"""Append one audit event. Never raises — audit logging must never break
a chat turn, a permission decision, or a tool call."""
try:
now = datetime.now()
event = {
"ts": now.isoformat(timespec="seconds"),
"kind": kind,
"agent_role": agent_role or "",
"name": name or "",
"ok": bool(ok),
"detail": (detail or "")[:2000], # bounded — never let a huge blob bloat the log
"account": _identity_account,
"role": _identity_role,
"machine": _identity_machine,
}
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
path = AUDIT_DIR / f"{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
_write_shared(event, now)
except Exception: # noqa: BLE001
pass
def _write_shared(event: Dict[str, Any], now: datetime) -> None:
"""Best-effort mirror of ``event`` into the shared cross-machine store —
one file PER MACHINE per day, so no two machines ever write the same
file. Never raises."""
if not _identity_shared_dir or not _identity_machine:
return
try:
shared = Path(_identity_shared_dir).expanduser() / "telemetry" / "audit"
shared.mkdir(parents=True, exist_ok=True)
path = shared / f"{_identity_machine}-{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception: # noqa: BLE001
pass
_logger.record(kind, name, ok, detail=detail, agent_role=agent_role)
def load_events(start: Optional[date] = None, end: Optional[date] = None,
@@ -91,25 +54,5 @@ def load_events(start: Optional[date] = None, end: Optional[date] = None,
"""Events between ``start``/``end`` (inclusive; None = unbounded),
optionally filtered to one ``kind`` — this IS how each Monitoring
Dashboard panel gets its own slice of the same underlying log."""
directory = directory or AUDIT_DIR
if not directory.exists():
return []
events: List[Dict[str, Any]] = []
for path in sorted(directory.glob("*.jsonl")):
try:
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
except ValueError:
continue
if (start and day < start) or (end and day > end):
continue
try:
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
event = json.loads(line)
if kind is not None and event.get("kind") != kind:
continue
events.append(event)
except (OSError, json.JSONDecodeError):
continue
return events
events = _logger.load_events(start=start, end=end, kind=kind, directory=directory)
return [e.to_dict() for e in events]
+6 -2
View File
@@ -18,6 +18,8 @@ existing ``core/skills.py`` registry.
"""
from __future__ import annotations
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
@@ -233,7 +235,9 @@ def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path:
directory = directory or WORKFLOWS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{wf.id}.json"
path.write_text(json.dumps(workflow_to_dict(wf), ensure_ascii=False, indent=2), encoding="utf-8")
# Tiêu chí nghiệm thu A: mọi thao tác ghi tệp đi qua AtomicJsonFile. Trước
# đây ghi thẳng, nên tắt máy giữa lúc lưu là mất luôn workflow.
AtomicJsonFile(path).write(workflow_to_dict(wf))
return path
@@ -307,7 +311,7 @@ def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> P
directory = directory or AGENTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{agent.id}.json"
path.write_text(json.dumps(agent_to_dict(agent), ensure_ascii=False, indent=2), encoding="utf-8")
AtomicJsonFile(path).write(agent_to_dict(agent))
return path
+6 -4
View File
@@ -13,6 +13,8 @@ the run that is currently open.
"""
from __future__ import annotations
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
from pathlib import Path
from typing import Dict, List, Optional
@@ -152,10 +154,10 @@ class Co4ERunManager(QObject):
payload = {"runs": [h.to_record() for h in runs]}
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8")
tmp.replace(path) # atomic — never leaves a half-written file
# AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync
# (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng
# Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows.
AtomicJsonFile(path).write(payload)
except OSError:
pass
+13 -3
View File
@@ -32,6 +32,14 @@ _SYMBOL_CCY = {"₫": "VND", "vnd": "VND", "đ": "VND",
_DEFAULT_UNIT = "Million tokens"
# Flat USD/1M-token fallback rates used by turn_cost_usd() when a model isn't
# in the price table. Owned here (not usage_tracker.DEFAULT_PRICING) so this
# module never needs to import usage_tracker — usage_tracker imports this
# module instead, keeping the dependency one-directional. Values match
# usage_tracker.DEFAULT_PRICING's price_per_mtok_in_usd/out_usd exactly.
_FALLBACK_RATE_IN_USD = 0.5
_FALLBACK_RATE_OUT_USD = 1.5
# ---- currency ------------------------------------------------------------
def _rates(config) -> Dict[str, float]:
@@ -138,9 +146,11 @@ def turn_cost_usd(model: str, in_tok: int, out_tok: int, config) -> float:
switches models (a different model → its own row / rates)."""
rates = usd_rates_for(model, config)
if rates is None:
from . import usage_tracker as ut
p = {**ut.DEFAULT_PRICING, **((getattr(config, "data", {}) or {}).get("usage") or {})}
rates = {"in": float(p["price_per_mtok_in_usd"]), "out": float(p["price_per_mtok_out_usd"])}
usage = (getattr(config, "data", {}) or {}).get("usage") or {}
rates = {
"in": float(usage.get("price_per_mtok_in_usd", _FALLBACK_RATE_IN_USD)),
"out": float(usage.get("price_per_mtok_out_usd", _FALLBACK_RATE_OUT_USD)),
}
return (in_tok or 0) / 1e6 * rates["in"] + (out_tok or 0) / 1e6 * rates["out"]
+56
View File
@@ -0,0 +1,56 @@
"""Dựng câu nhắc cho AI phân tích mức dùng — R09-T02.
Chỉ sinh văn bản. Tách riêng vì đây là phần dễ đổi nhất (câu chữ, cột hiển
thị) và không liên quan tới việc ghi nhận hay tính tiền.
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
from .usage_periods import period_breakdown, period_range_label
_AI_ANALYSIS_HEADERS = {
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
}
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
numbers only — never raw prompt contents — asking for concrete habits
feedback and token-saving recommendations, in the CURRENTLY SELECTED
display language (headers included — not just the model's free-text reply,
which would otherwise leave the section titles in Vietnamese regardless of
the app's language setting)."""
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
top = "\n".join(f"- {label}: {tok:,} tokens"
for label, tok in summary.get("top_labels", []))
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
return (
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
"scheduled agent tasks). Analyze this usage summary and give the user "
"practical advice, replying in "
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
f"Period stats: {summary.get('turns', 0)} turns, "
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
f"cache={summary.get('cache', 0):,}, "
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
f"Top consumers:\n{top or '- (none)'}\n"
f"By area: {by_source or '(none)'}\n"
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
f"2. **{h2}** — 3 concrete prompt-writing tips "
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
"first; many small turns → batch questions).\n"
f"3. **{h3}** — 2-3 app-level actions (compact history, "
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
"Keep the whole reply under 250 words."
)
+101
View File
@@ -0,0 +1,101 @@
"""Bảng giá và quy đổi token thành tiền — R09-T02.
Tách khỏi ``usage_tracker.py``: ghi nhận mức dùng và tính tiền là hai việc
khác nhau. Bảng giá đổi theo nhà cung cấp, cách ghi nhận thì không.
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
DEFAULT_PRICING = {
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
"currency": "USD", # display currency: USD | VND | JPY
"usd_to_vnd": 25000.0,
"usd_to_jpy": 150.0,
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
# Events whose model has an entry are costed with ITS rates; everything
# else falls back to the flat price_per_mtok_* rates above. Edited in the
# Monitoring Overview's pricing table.
"model_prices": {},
# Reference URL of the price list the table was filled from (set in
# Settings; shown as a link beside the table — informational only, the
# app never scrapes it).
"pricing_url": "",
}
_CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
p = {**DEFAULT_PRICING, **(pricing or {})}
return {
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
}
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
table applies: an event whose ``model`` has an entry in
``pricing["model_prices"]`` is costed with that model's own rates; any
other event uses the flat ``price_per_mtok_*`` rates. With an empty
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
p = {**DEFAULT_PRICING, **(pricing or {})}
table = p.get("model_prices") or {}
flat = {"in": float(p["price_per_mtok_in_usd"]),
"out": float(p["price_per_mtok_out_usd"]),
"cache": float(p["price_per_mtok_cache_usd"])}
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
for e in events:
rates = table.get(e.get("model", "")) or {}
for bucket in ("in", "out", "cache"):
try:
rate = float(rates.get(bucket, flat[bucket]))
except (TypeError, ValueError):
rate = flat[bucket]
out[bucket] += e.get(bucket, 0) / 1e6 * rate
return out
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
"""Format a USD amount in the display currency. ``digits`` caps the number
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
currency that uses fewer (VND stays whole, JPY one place)."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
if digits is not None:
cur_digits = min(cur_digits, digits)
value = usd * rate
return f"{symbol}{value:,.{cur_digits}f}"
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
y-axis label box is narrow; the longer full-precision string used to
overflow it, visually clipping/obscuring the leading currency symbol."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
value = usd * rate
sign = "-" if value < 0 else ""
value = abs(value)
if value >= 1_000_000:
body = f"{value / 1_000_000:,.2f}M"
elif value >= 1_000:
body = f"{value / 1_000:,.2f}K"
else:
body = f"{value:,.2f}"
return f"{sign}{symbol}{body}"
+144
View File
@@ -0,0 +1,144 @@
"""Gộp mức dùng theo khoảng thời gian — R09-T02.
Ngày / tuần / tháng / quý: ranh giới khoảng, nhãn hiển thị, chuỗi số vẽ biểu
đồ. Thuần tính toán trên danh sách sự kiện, không đụng đĩa.
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
from .usage_cost import cost_usd_events
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
from collections import OrderedDict
pricing = pricing or {}
def _key(ts: Any) -> str:
s = str(ts or "")[:10]
if granularity == "year":
return s[:4]
if granularity == "month":
return s[:7]
return s
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
k = _key(e.get("ts"))
if k:
buckets.setdefault(k, []).append(e)
out = []
for k, evs in buckets.items():
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values())
out.append((k, tokens, cost))
if last and len(out) > last:
out = out[-last:]
return out
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
"""[start, end) dates of the period ``offset`` periods from the current one
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
from datetime import timedelta
today = today or date.today()
if gran == "week":
monday = today - timedelta(days=today.weekday()) # Monday of this week
start = monday + timedelta(weeks=offset)
return start, start + timedelta(days=7)
if gran == "year":
y = today.year + offset
return date(y, 1, 1), date(y + 1, 1, 1)
# month (default)
base = today.year * 12 + (today.month - 1) + offset
y, m = divmod(base, 12)
y2, m2 = divmod(base + 1, 12)
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
def _period_label(gran: str, start: date) -> str:
if gran == "week":
return start.isoformat() # the week's Monday (YYYY-MM-DD)
if gran == "year":
return str(start.year)
return start.strftime("%Y-%m")
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
pricing: Dict[str, Any]) -> tuple:
lo, hi = start.isoformat(), end.isoformat()
evs = [e for e in events if lo <= str(e.get("ts", ""))[:10] < hi]
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
return tokens, cost
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> tuple:
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
start, end = period_bounds(gran, offset, today)
return _sum_between(events, start, end, pricing)
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
pages the window into the past for the Dashboard's prev/next navigation."""
out = []
for i in range(count - 1, -1, -1):
start, end = period_bounds(gran, offset - i, today)
tok, cost = _sum_between(events, start, end, pricing)
out.append((_period_label(gran, start), tok, cost))
return out
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
→ ``[(label, tokens, cost_usd)]``:
· week → 7 days Mon→Sun (label ``MM/DD``)
· month → weeks W1…Wn (7-day chunks from the 1st)
· year → 12 months (label ``01``…``12``)."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
out = []
if gran == "week":
for i in range(7):
d = start + timedelta(days=i)
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
out.append((d.strftime("%m/%d"), tok, cost))
elif gran == "year":
for m in range(1, 13):
ms = date(start.year, m, 1)
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
tok, cost = _sum_between(events, ms, me, pricing)
out.append((f"{m:02d}", tok, cost))
else: # month → weeks W1..Wn
ndays = (end - start).days
wk, day = 1, 1
while day <= ndays:
ws = date(start.year, start.month, day)
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
tok, cost = _sum_between(events, ws, we, pricing)
out.append((f"W{wk}", tok, cost))
wk += 1
day += 7
return out
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
"""Human label for the selected period (shown in the Dashboard header) —
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
if gran == "week":
last_day = end - timedelta(days=1)
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
if gran == "year":
return str(start.year)
return start.strftime("%Y/%m")
+12 -241
View File
@@ -12,6 +12,17 @@ The turn's source/label is set by the caller ON THE WORKER THREAD via
"""
from __future__ import annotations
# Giữ đường vào cũ: nhiều nơi import mấy tên này thẳng từ usage_tracker.
from .usage_ai_report import build_ai_analysis_prompt # noqa: F401
from .usage_cost import ( # noqa: F401
DEFAULT_PRICING, SUPPORTED_CURRENCIES, cost_usd, cost_usd_events,
format_cost, format_cost_compact,
)
from .usage_periods import ( # noqa: F401
bucketed_series, period_bounds, period_breakdown, period_range_label,
period_totals, period_window,
)
import json
import threading
from datetime import date, datetime
@@ -19,6 +30,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
USAGE_DIR = CONFIG_DIR / "usage"
@@ -203,198 +215,30 @@ def summarize(events: List[Dict[str, Any]]) -> Dict[str, Any]:
# ---- cost ------------------------------------------------------------------
DEFAULT_PRICING = {
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
"currency": "USD", # display currency: USD | VND | JPY
"usd_to_vnd": 25000.0,
"usd_to_jpy": 150.0,
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
# Events whose model has an entry are costed with ITS rates; everything
# else falls back to the flat price_per_mtok_* rates above. Edited in the
# Monitoring Overview's pricing table.
"model_prices": {},
# Reference URL of the price list the table was filled from (set in
# Settings; shown as a link beside the table — informational only, the
# app never scrapes it).
"pricing_url": "",
}
_CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
# Currencies the display picker offers — exactly the ones format_cost() can
# actually convert to (symbol/precision above + a usd_to_* rate below).
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
p = {**DEFAULT_PRICING, **(pricing or {})}
return {
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
}
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
table applies: an event whose ``model`` has an entry in
``pricing["model_prices"]`` is costed with that model's own rates; any
other event uses the flat ``price_per_mtok_*`` rates. With an empty
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
p = {**DEFAULT_PRICING, **(pricing or {})}
table = p.get("model_prices") or {}
flat = {"in": float(p["price_per_mtok_in_usd"]),
"out": float(p["price_per_mtok_out_usd"]),
"cache": float(p["price_per_mtok_cache_usd"])}
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
for e in events:
rates = table.get(e.get("model", "")) or {}
for bucket in ("in", "out", "cache"):
try:
rate = float(rates.get(bucket, flat[bucket]))
except (TypeError, ValueError):
rate = flat[bucket]
out[bucket] += e.get(bucket, 0) / 1e6 * rate
return out
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
from collections import OrderedDict
pricing = pricing or {}
def _key(ts: Any) -> str:
s = str(ts or "")[:10]
if granularity == "year":
return s[:4]
if granularity == "month":
return s[:7]
return s
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
k = _key(e.get("ts"))
if k:
buckets.setdefault(k, []).append(e)
out = []
for k, evs in buckets.items():
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values())
out.append((k, tokens, cost))
if last and len(out) > last:
out = out[-last:]
return out
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
"""[start, end) dates of the period ``offset`` periods from the current one
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
from datetime import timedelta
today = today or date.today()
if gran == "week":
monday = today - timedelta(days=today.weekday()) # Monday of this week
start = monday + timedelta(weeks=offset)
return start, start + timedelta(days=7)
if gran == "year":
y = today.year + offset
return date(y, 1, 1), date(y + 1, 1, 1)
# month (default)
base = today.year * 12 + (today.month - 1) + offset
y, m = divmod(base, 12)
y2, m2 = divmod(base + 1, 12)
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
def _period_label(gran: str, start: date) -> str:
if gran == "week":
return start.isoformat() # the week's Monday (YYYY-MM-DD)
if gran == "year":
return str(start.year)
return start.strftime("%Y-%m")
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
pricing: Dict[str, Any]) -> tuple:
lo, hi = start.isoformat(), end.isoformat()
evs = [e for e in events if lo <= str(e.get("ts", ""))[:10] < hi]
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
return tokens, cost
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> tuple:
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
start, end = period_bounds(gran, offset, today)
return _sum_between(events, start, end, pricing)
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
pages the window into the past for the Dashboard's prev/next navigation."""
out = []
for i in range(count - 1, -1, -1):
start, end = period_bounds(gran, offset - i, today)
tok, cost = _sum_between(events, start, end, pricing)
out.append((_period_label(gran, start), tok, cost))
return out
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
→ ``[(label, tokens, cost_usd)]``:
· week → 7 days Mon→Sun (label ``MM/DD``)
· month → weeks W1…Wn (7-day chunks from the 1st)
· year → 12 months (label ``01``…``12``)."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
out = []
if gran == "week":
for i in range(7):
d = start + timedelta(days=i)
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
out.append((d.strftime("%m/%d"), tok, cost))
elif gran == "year":
for m in range(1, 13):
ms = date(start.year, m, 1)
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
tok, cost = _sum_between(events, ms, me, pricing)
out.append((f"{m:02d}", tok, cost))
else: # month → weeks W1..Wn
ndays = (end - start).days
wk, day = 1, 1
while day <= ndays:
ws = date(start.year, start.month, day)
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
tok, cost = _sum_between(events, ws, we, pricing)
out.append((f"W{wk}", tok, cost))
wk += 1
day += 7
return out
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
"""Human label for the selected period (shown in the Dashboard header) —
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
if gran == "week":
last_day = end - timedelta(days=1)
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
if gran == "year":
return str(start.year)
return start.strftime("%Y/%m")
def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
@@ -409,7 +253,6 @@ def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
order and ``budget_set_at`` only has 1-second resolution, so a timestamp
cutoff could mis-include/exclude an event recorded in that same second —
the count baseline is exact regardless of timing."""
from . import model_pricing as mp
usage = config.data.setdefault("usage", {})
ccy = (currency or usage.get("currency") or "USD").upper()
usage["budget_amount_usd"] = mp.convert(float(amount or 0), ccy, "USD", config)
@@ -454,83 +297,11 @@ def budget_status(config) -> Optional[Dict[str, Any]]:
}
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
"""Format a USD amount in the display currency. ``digits`` caps the number
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
currency that uses fewer (VND stays whole, JPY one place)."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
if digits is not None:
cur_digits = min(cur_digits, digits)
value = usd * rate
return f"{symbol}{value:,.{cur_digits}f}"
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
y-axis label box is narrow; the longer full-precision string used to
overflow it, visually clipping/obscuring the leading currency symbol."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
value = usd * rate
sign = "-" if value < 0 else ""
value = abs(value)
if value >= 1_000_000:
body = f"{value / 1_000_000:,.2f}M"
elif value >= 1_000:
body = f"{value / 1_000:,.2f}K"
else:
body = f"{value:,.2f}"
return f"{sign}{symbol}{body}"
_AI_ANALYSIS_HEADERS = {
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
}
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
numbers only — never raw prompt contents — asking for concrete habits
feedback and token-saving recommendations, in the CURRENTLY SELECTED
display language (headers included — not just the model's free-text reply,
which would otherwise leave the section titles in Vietnamese regardless of
the app's language setting)."""
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
top = "\n".join(f"- {label}: {tok:,} tokens"
for label, tok in summary.get("top_labels", []))
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
return (
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
"scheduled agent tasks). Analyze this usage summary and give the user "
"practical advice, replying in "
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
f"Period stats: {summary.get('turns', 0)} turns, "
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
f"cache={summary.get('cache', 0):,}, "
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
f"Top consumers:\n{top or '- (none)'}\n"
f"By area: {by_source or '(none)'}\n"
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
f"2. **{h2}** — 3 concrete prompt-writing tips "
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
"first; many small turns → batch questions).\n"
f"3. **{h3}** — 2-3 app-level actions (compact history, "
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
"Keep the whole reply under 250 words."
)
+68
View File
@@ -0,0 +1,68 @@
# Tin nhắn gửi Team Hoa — 25/08/2026
*Nam (Team Gamma) soạn. Hai việc, không cần trả lời, chỉ cần đọc trước khi
bắt đầu R07-T03 và R06.*
---
Chào team Hoa,
Có hai thứ trong nhánh `gamma/refactor` ảnh hưởng trực tiếp tới phần các bạn
sắp làm. Gửi trước để khỏi mất thời gian truy lỗi.
## 1. `platform/` đã đổi tên thành `adapters/` — plan.md ghi tên cũ
Plan chỉ đích danh `platform/qt/qt_scheduler_clock.py` (R07-T03, dòng 407 và
lịch 25/08 ở dòng 229). **Đừng tạo thư mục `platform/`.**
Lý do: `platform` là tên một module trong thư viện chuẩn của Python. Tạo thư
mục `platform/` ở gốc repo là nó che mất module chuẩn khi chạy từ chính thư
mục gốc — mà đó là cách toàn bộ script trong `tools/` và `scripts/` đang chạy.
Triệu chứng không hề chỉ về đúng chỗ:
AttributeError: module 'platform' has no attribute 'system'
Ném ra từ `import keyring`, không liên quan gì tới file bạn vừa tạo.
Tôi đã mắc đúng lỗi này hôm 21/08. Lúc thử thì đứng ở thư mục cha nên không
tái hiện được, tưởng an toàn. Đổi tên thành `adapters/` và thêm
`tests/test_no_stdlib_shadow.py` để lần sau đỏ ngay.
**Việc cần làm**: tạo `adapters/qt/qt_scheduler_clock.py` thay vì
`platform/qt/...`. Thư mục `adapters/qt/` đã có sẵn `__init__.py` trên nhánh
`gamma/refactor`, kéo về là dùng được.
## 2. `AtomicJsonFile` vừa vá một lỗi Windows — lấy bản mới trước khi dựng lên
Plan giao các bạn hai repository ngồi trên `AtomicJsonFile`:
* `infrastructure/persistence/json/task_repository_impl.py` (R07-T01, dòng 403)
* `infrastructure/persistence/json/workspace_repository_impl.py` (R06, dòng 387)
Và tiêu chí nghiệm thu **A** (dòng 244) bắt mọi thao tác ghi tệp phải đi qua nó.
Hôm nay tôi bắt được lỗi thật trong đó:
PermissionError: [WinError 5] Access is denied
.dem.json.xxxxxxx.tmp -> dem.json
`os.replace` trên Windows bị từ chối khi Defender hoặc Search Indexer đang giữ
handle lên file vừa tạo — vài chục mili-giây rồi nhả. Đo được: hỏng 1 trong 7
lượt chạy 20 lần ghi, tức **khoảng 1 trên 140 lần lưu**. Người dùng thỉnh
thoảng bấm Lưu là văng lỗi và không tài nào tái hiện để báo.
Đã thêm vòng thử lại (commit `9d6a7be`). Nếu các bạn dựng repository trên bản
trước đó thì lưu task và lưu workspace cũng hỏng với tần suất y hệt — nhân lên
ba nơi ghi file.
**Việc cần làm**: `git pull` nhánh `gamma/refactor` (hoặc chờ nó vào `main`)
trước khi bắt đầu R06/R07-T01.
## Tiện thể
`domain/security/tool_policy.py` là bản đề xuất DTO `ToolPolicyGateway` tôi
viết hộ cho R05 của các bạn — ba trạng thái ALLOW/DENY/ASK, kèm fake và test
contract. Không có gì của Gamma phụ thuộc vào nó, nên các bạn cứ sửa hoặc bỏ
thoải mái, không phải giữ ý.
Nam
+23 -3004
View File
File diff suppressed because it is too large Load Diff
+344
View File
@@ -0,0 +1,344 @@
"""Chuỗi hiển thị — phần agents_admin_tab.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"co4e.tt_flow_name": {"en": "Flow name", "ja": "フロー名", "vi": "Tên flow"},
"co4e.tt_add_step": {"en": "Add a step to the canvas", "ja": "キャンバスにステップを追加",
"vi": "Thêm một bước vào canvas"},
"co4e.tt_zoom_in": {"en": "Zoom in (Ctrl+wheel / Ctrl++)", "ja": "拡大(Ctrl+ホイール / Ctrl++)",
"vi": "Phóng to (Ctrl+lăn chuột / Ctrl++)"},
"co4e.tt_zoom_out": {"en": "Zoom out (Ctrl+wheel / Ctrl+-)", "ja": "縮小(Ctrl+ホイール / Ctrl+-)",
"vi": "Thu nhỏ (Ctrl+lăn chuột / Ctrl+-)"},
"co4e.run_bg": {"en": "Run", "ja": "実行", "vi": "Chạy"},
"co4e.tt_run_bg": {
"en": "Run the selected flow in the background — several flows run in parallel",
"ja": "選択フローをバックグラウンド実行 — 複数フローを並列実行",
"vi": "Chạy flow đã chọn ở nền — nhiều flow chạy song song"},
"co4e.running_flows": {"en": "Running flows", "ja": "実行中のフロー", "vi": "Flow đang chạy"},
"co4e.runs_tab": {"en": "Flow Status", "ja": "フロー状態", "vi": "Flow Status"},
# The flow tab strip was removed, so its pinned Flow Status tab became a
# toggle in the flow toolbar — and that page needs its own way back.
"co4e.tt_runs_tab": {
"en": "Show every flow run", "ja": "すべてのフロー実行を表示",
"vi": "Xem toàn bộ lần chạy flow"},
"co4e.back_to_flow": {"en": "Back to flow", "ja": "フローに戻る", "vi": "Về flow"},
"co4e.new_flow_ready": {
"en": "New flow — type a name, then drag agents onto the canvas",
"ja": "新しいフロー — 名前を入力し、エージェントをキャンバスへ",
"vi": "Flow mới — đặt tên rồi kéo agent vào canvas"},
"co4e.tt_back_to_flow": {
"en": "Back to the flow editor", "ja": "フローエディタに戻る",
"vi": "Quay lại màn dựng flow"},
"co4e.runs_tab_n": {"en": "Flow Status ({n})", "ja": "フロー状態 ({n})", "vi": "Flow Status ({n})"},
"co4e.runs_col_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"co4e.runs_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"co4e.runs_col_steps": {"en": "Steps", "ja": "ステップ", "vi": "Bước"},
"co4e.runs_col_by": {"en": "Created by", "ja": "作成者", "vi": "Người tạo"},
"co4e.runs_col_at": {"en": "Created at", "ja": "作成日時", "vi": "Ngày tạo"},
"co4e.tt_runs_list": {
"en": "Live status of every running/finished flow — always up to date. Double-click a run to run that flow again.",
"ja": "実行中/完了フローのライブ状態 — 常に最新。実行をダブルクリックでそのフローを再実行。",
"vi": "Trạng thái trực tiếp của mọi flow đang chạy/đã xong — luôn mới nhất. Nhấp đúp để chạy lại flow đó."},
"co4e.flow_gone": {"en": "That flow no longer exists.", "ja": "そのフローは存在しません。",
"vi": "Flow đó không còn tồn tại."},
"co4e.rename": {"en": "Rename", "ja": "名前を変更", "vi": "Đổi tên"},
"co4e.rename_prompt": {"en": "New flow name (name it by its function / task):",
"ja": "新しいフロー名(機能/タスクで命名):",
"vi": "Tên flow mới (đặt theo chức năng / task):"},
"co4e.renamed_msg": {"en": "Renamed to: {name}", "ja": "名前変更: {name}", "vi": "Đã đổi tên: {name}"},
"co4e.duplicate": {"en": "Duplicate", "ja": "複製", "vi": "Nhân bản"},
"co4e.viewing_flow": {"en": "Viewing flow: {name}", "ja": "フロー表示: {name}", "vi": "Đang xem flow: {name}"},
"co4e.stop": {"en": "Stop", "ja": "停止", "vi": "Dừng"},
"co4e.tt_stop_run": {"en": "Stop the selected run (or all runs if none selected)",
"ja": "選択した実行を停止(未選択なら全実行)", "vi": "Dừng lần chạy đã chọn (hoặc tất cả nếu chưa chọn)"},
"co4e.clear_done": {"en": "Clear done", "ja": "完了を消去", "vi": "Xóa đã xong"},
"co4e.tt_clear_runs": {"en": "Remove finished/stopped runs from the list",
"ja": "完了/停止した実行を一覧から削除", "vi": "Bỏ các lần chạy đã xong/đã dừng khỏi danh sách"},
"co4e.select_flow": {"en": "Select a flow first.", "ja": "先にフローを選択してください。",
"vi": "Hãy chọn một flow trước."},
"co4e.delete_run": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"co4e.tt_delete_run": {"en": "Delete the selected run from the history",
"ja": "選択した実行を履歴から削除", "vi": "Xóa lần chạy đang chọn khỏi lịch sử"},
"co4e.open_run": {"en": "Open flow", "ja": "フローを開く", "vi": "Mở flow"},
"co4e.open_output": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
"co4e.open_output_link": {
"en": "📂 Open output folder", "ja": "📂 出力フォルダを開く", "vi": "📂 Mở thư mục output"},
"co4e.tt_open_workspace": {
"en": "Open the workspace folder where flow outputs are saved:\n{path}",
"ja": "フローの出力が保存されるワークスペースフォルダを開く:\n{path}",
"vi": "Mở thư mục workspace nơi lưu output của flow:\n{path}"},
"co4e.select_run": {"en": "Select a run first.", "ja": "先に実行を選択してください。",
"vi": "Hãy chọn một lần chạy trước."},
"co4e.rename_run": {"en": "Rename", "ja": "名前変更", "vi": "Đổi tên"},
"co4e.tt_rename_run": {"en": "Rename the selected flow run",
"ja": "選択した実行の名前を変更", "vi": "Đổi tên lần chạy đang chọn"},
"co4e.rename_run_label": {"en": "New flow name:", "ja": "新しいフロー名:", "vi": "Tên flow mới:"},
"co4e.run_done_title": {"en": "Flow finished", "ja": "フロー完了", "vi": "Flow đã xong"},
"co4e.run_done_popup": {
"en": "Flow \"{name}\" finished — {status}.",
"ja": "フロー「{name}」が完了しました — {status}。",
"vi": "Flow \"{name}\" đã chạy xong — {status}."},
"co4e.duplicated_msg": {"en": "Duplicated: {name}", "ja": "複製しました: {name}", "vi": "Đã nhân bản: {name}"},
"co4e.bg_started": {"en": "▶ Started in background: {name}", "ja": "▶ バックグラウンドで開始: {name}",
"vi": "▶ Đã chạy nền: {name}"},
"co4e.bg_done": {"en": "Flow '{name}': {status}", "ja": "フロー '{name}': {status}",
"vi": "Flow '{name}': {status}"},
"co4e.tool_failed": {"en": "⚠ tool failed: {name}", "ja": "⚠ ツール失敗: {name}", "vi": "⚠ tool lỗi: {name}"},
"co4e.manual_started": {"en": "▶ Manual run: {name} — advance with Run/Next step.",
"ja": "▶ 手動実行: {name} — 「実行/次へ」で進む。",
"vi": "▶ Chạy thủ công: {name} — bấm Chạy/Bước tiếp để tiến."},
"co4e.manual_step": {"en": "▶ Step {i}/{n}: {label}", "ja": "▶ ステップ {i}/{n}: {label}",
"vi": "▶ Bước {i}/{n}: {label}"},
"co4e.status.running": {"en": "running", "ja": "実行中", "vi": "đang chạy"},
"co4e.status.done": {"en": "done", "ja": "完了", "vi": "xong"},
"co4e.status.error": {"en": "error", "ja": "エラー", "vi": "lỗi"},
"co4e.status.stopped": {"en": "stopped", "ja": "停止", "vi": "đã dừng"},
"co4e.chat_placeholder": {
"en": "Chat with the flow — use /agent:<name> or /skill:<name>",
"ja": "フローとチャット — /agent:<name> または /skill:<name>",
"vi": "Chat với flow — dùng /agent:<name> hoặc /skill:<name>"},
"co4e.send": {"en": "Send", "ja": "送信", "vi": "Gửi"},
"co4e.tab_basic": {"en": "Basic", "ja": "基本", "vi": "Cơ bản"},
"co4e.tab_model_perm": {"en": "Model & Permission", "ja": "モデルと権限", "vi": "Model & Quyền"},
"co4e.tab_skills_files": {"en": "Skills & Files", "ja": "スキルとファイル", "vi": "Skills & Tệp"},
"co4e.f_label": {"en": "Label", "ja": "ラベル", "vi": "Nhãn"},
"co4e.f_role": {"en": "Role", "ja": "ロール", "vi": "Vai trò"},
"co4e.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"co4e.f_icon": {"en": "Icon", "ja": "アイコン", "vi": "Icon"},
"co4e.f_icon_placeholder": {"en": "icon name (optional)", "ja": "アイコン名(任意)", "vi": "tên icon (tùy chọn)"},
"co4e.f_instructions": {"en": "Instructions", "ja": "指示", "vi": "Hướng dẫn"},
"co4e.f_context": {"en": "Context", "ja": "コンテキスト", "vi": "Ngữ cảnh"},
"co4e.f_context_placeholder": {
"en": "Extra background/info for this agent or step (added to its prompt at run time).",
"ja": "このエージェント/ステップ用の追加情報(実行時にプロンプトへ追加されます)。",
"vi": "Thông tin/ngữ cảnh bổ sung cho agent hoặc step này (thêm vào prompt khi chạy)."},
"co4e.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
"co4e.f_permission": {"en": "Permissions", "ja": "権限", "vi": "Quyền"},
"co4e.f_self_verify": {"en": "Self-verify", "ja": "自己検証", "vi": "Tự kiểm tra"},
"co4e.f_verify_rounds": {"en": "rounds", "ja": "回数", "vi": "vòng"},
"co4e.f_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
"co4e.f_attachments": {"en": "Attachments", "ja": "添付ファイル", "vi": "Tệp đính kèm"},
"co4e.attach_add": {"en": "Attach files", "ja": "ファイル添付", "vi": "Đính kèm tệp"},
"co4e.attach_remove": {"en": "Remove", "ja": "削除", "vi": "Bỏ"},
"co4e.f_subagents": {"en": "Parallel agents", "ja": "並列エージェント", "vi": "Agent song song"},
"co4e.perm.inherit": {"en": "Inherit", "ja": "継承", "vi": "Kế thừa"},
"co4e.perm.read-only": {"en": "Read-only", "ja": "読み取り専用", "vi": "Chỉ đọc"},
"co4e.perm.standard": {"en": "Standard", "ja": "標準", "vi": "Tiêu chuẩn"},
"co4e.perm.full": {"en": "Full", "ja": "フル", "vi": "Toàn quyền"},
"co4e.add_subagent": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"co4e.del_subagent": {"en": "Remove", "ja": "削除", "vi": "Bỏ"},
"co4e.run_this_step": {"en": "Run this step", "ja": "このステップを実行", "vi": "Chạy bước này"},
"co4e.run_from_here": {"en": "Run from here", "ja": "ここから実行", "vi": "Chạy từ đây"},
"co4e.delete_step": {"en": "Delete step", "ja": "ステップ削除", "vi": "Xóa bước"},
"co4e.load_models_tooltip": {
"en": "Load available models", "ja": "利用可能なモデルを取得", "vi": "Tải danh sách model"},
"co4e.agent_edit_title": {"en": "Edit agent", "ja": "エージェント編集", "vi": "Sửa agent"},
"co4e.agent_new_title": {"en": "New agent", "ja": "新規エージェント", "vi": "Agent mới"},
"co4e.saved_msg": {"en": "Saved flow: {name}", "ja": "フローを保存: {name}", "vi": "Đã lưu flow: {name}"},
"co4e.select_custom_agent": {
"en": "Select a custom agent first.", "ja": "先にカスタムエージェントを選択してください。",
"vi": "Hãy chọn một agent tùy chỉnh trước."},
"co4e.no_steps": {"en": "Add at least one step first.", "ja": "先にステップを追加してください。",
"vi": "Hãy thêm ít nhất một bước."},
"co4e.run_started": {"en": "▶ Running flow: {name}", "ja": "▶ フロー実行中: {name}",
"vi": "▶ Đang chạy flow: {name}"},
"co4e.run_done": {"en": "✓ Flow finished.", "ja": "✓ フロー完了。", "vi": "✓ Flow xong."},
"co4e.run_execute_phase": {
"en": "▶ Plan done — now executing…", "ja": "▶ 計画完了 — 実行中…",
"vi": "▶ Xong plan — đang thực thi…"},
"co4e.agent_not_found": {
"en": "Agent '{name}' not found.", "ja": "エージェント '{name}' が見つかりません。",
"vi": "Không tìm thấy agent '{name}'."},
# ---- agents_admin_tab.py — Admin-only agent catalog -------------------
"agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"},
"agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agents_admin.hint": {
"en": "System-management agents shared across every machine (stored in the shared accounts folder): the help agent and Schedule Task executors. These are NOT the agents you pick in Cowork or Co4E.",
"ja": "全マシンで共有されるシステム管理用エージェント(共有フォルダーに保存):ヘルプエージェントやスケジュールタスクの実行エージェントなど。CoworkやCo4Eで選択するエージェントではありません。",
"vi": "Agent quản lý hệ thống, dùng chung mọi máy (lưu trong thư mục dùng chung): agent trợ giúp và agent chạy Schedule Task. Đây KHÔNG phải agent để chọn trong Cowork hay Co4E."},
"agents_admin.add_title": {"en": "Add agent", "ja": "エージェント追加", "vi": "Thêm agent"},
"agents_admin.edit_title": {"en": "Edit agent", "ja": "エージェント編集", "vi": "Sửa agent"},
"agents_admin.delete_title": {"en": "Delete agent", "ja": "エージェント削除", "vi": "Xóa agent"},
"agents_admin.delete_confirm": {
"en": "Delete agent \"{name}\"?", "ja": "エージェント「{name}」を削除しますか?",
"vi": "Xóa agent \"{name}\"?"},
"agents_admin.add_btn": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"agents_admin.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"agents_admin.f_kind": {"en": "App function", "ja": "アプリ機能", "vi": "Chức năng App"},
"agents_admin.f_prompt": {"en": "Instructions", "ja": "指示", "vi": "Chỉ dẫn"},
"agents_admin.f_prompt_placeholder": {
"en": "Extra instructions this agent always follows (optional)…",
"ja": "このエージェントが常に従う追加指示(任意)…",
"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.provider_default": {
"en": "(machine's active provider)", "ja": "(各マシンの現在のプロバイダー)",
"vi": "(provider hiện tại của máy)"},
"agents_admin.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
"agents_admin.f_model_placeholder": {
"en": "empty = each machine's Settings model (currently: {model})",
"ja": "空欄 = 各マシンの設定モデル(現在: {model})",
"vi": "để trống = model trong Settings của từng máy (hiện tại: {model})"},
"agents_admin.load_models_tooltip": {
"en": "Fetch this provider's real model list so you can pick a specific one from the dropdown.",
"ja": "このプロバイダーの実際のモデル一覧を取得し、ドロップダウンから選択できるようにします。",
"vi": "Lấy danh sách model thực tế của provider này để chọn từ dropdown."},
"agents_admin.load_models_empty": {
"en": "No models were returned — check the provider's settings/connection.",
"ja": "モデルが取得できませんでした。プロバイダーの設定/接続を確認してください。",
"vi": "Không lấy được model nào — kiểm tra lại cấu hình/kết nối provider."},
"agents_admin.f_enabled": {"en": "Enabled", "ja": "有効", "vi": "Kích hoạt"},
"agents_admin.col_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"agents_admin.col_kind": {"en": "Function", "ja": "機能", "vi": "Chức năng"},
"agents_admin.col_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
"agents_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Kích hoạt"},
"agents_admin.col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"agents_admin.col_updated": {"en": "Updated", "ja": "更新", "vi": "Cập nhật"},
"agents_admin.check_btn": {"en": "Check all", "ja": "すべてチェック", "vi": "Kiểm tra tất cả"},
"agents_admin.check_tooltip": {
"en": "Check each agent's effective provider/model connectivity",
"ja": "各エージェントの実効プロバイダ/モデルの接続性を確認",
"vi": "Kiểm tra kết nối provider/model hiệu lực của từng agent"},
"agents_admin.status_unchecked": {"en": "— (not checked)", "ja": "— (未チェック)", "vi": "— (chưa kiểm tra)"},
"agents_admin.status_unchecked_tip": {
"en": "Press Check to test whether this agent's provider/model is reachable",
"ja": "「チェック」でこのエージェントのプロバイダ/モデルへの到達性をテスト",
"vi": "Nhấn Kiểm tra để test agent này có kết nối được provider/model không"},
"agents_admin.status_checking": {"en": "checking…", "ja": "確認中…", "vi": "đang kiểm tra…"},
"agents_admin.status_ok": {"en": "Active", "ja": "稼働中", "vi": "Hoạt động"},
"agents_admin.status_bad": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
"agents_admin.default_model": {
"en": "(Settings default: {model})", "ja": "(設定既定: {model})",
"vi": "(mặc định Settings: {model})"},
"agents_admin.kind.search": {"en": "Search", "ja": "検索", "vi": "Tìm kiếm"},
"agents_admin.kind.monitor": {"en": "Monitoring", "ja": "監視", "vi": "Giám sát"},
"agents_admin.kind.cowork": {"en": "Cowork chat", "ja": "Cowork チャット", "vi": "Cowork chat"},
"agents_admin.kind.graphrag": {"en": "GraphRAG / Knowledge", "ja": "GraphRAG / ナレッジ", "vi": "GraphRAG / Tri thức"},
"agents_admin.kind.schedule": {"en": "Schedule Task", "ja": "スケジュールタスク", "vi": "Schedule Task"},
"agents_admin.kind.security": {"en": "Security", "ja": "セキュリティ", "vi": "Bảo mật"},
"agents_admin.kind.help": {"en": "App Help", "ja": "アプリヘルプ", "vi": "Trợ giúp App"},
"monitoring.filter_placeholder": {
"en": "Filter rows (or type a question and press )…",
"ja": "行をフィルター(質問を入力しても可)…",
"vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"},
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
"monitoring.pricing_title": {
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
"vi": "Bảng giá model (USD / 1 triệu token)"},
"monitoring.pricing_col_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
"monitoring.pricing_col_in": {"en": "In", "ja": "入力", "vi": "In"},
"monitoring.pricing_col_out": {"en": "Out", "ja": "出力", "vi": "Out"},
"monitoring.pricing_col_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"},
"monitoring.pricing_add_btn": {"en": "Add model", "ja": "モデル追加", "vi": "Thêm model"},
"monitoring.pricing_del_btn": {"en": "Remove", "ja": "削除", "vi": "Xóa"},
"monitoring.pricing_link_label": {
"en": "Reference:", "ja": "参考リンク:", "vi": "Link tham khảo:"},
"monitoring.pricing_no_link": {
"en": "No reference link set (Settings → Parameter → Pricing reference link).",
"ja": "参考リンク未設定(設定 → Parameter)。",
"vi": "Chưa đặt link tham khảo (Settings → Parameter → Link bảng giá)."},
"monitoring.ai_filter_tooltip": {
"en": "AI turns your question into a filter keyword (e.g. \"which commands failed today?\").",
"ja": "質問をAIがフィルターキーワードに変換します。",
"vi": "AI chuyển câu hỏi thành từ khóa lọc (vd: \"hôm nay lệnh nào bị lỗi?\")."},
"monitoring.security_detail_title": {
"en": "Event details", "ja": "イベント詳細", "vi": "Chi tiết sự kiện"},
"monitoring.security_detail_close": {
"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"monitoring.security_events_title": {
"en": "Security Events", "ja": "セキュリティイベント", "vi": "Sự kiện bảo mật"},
"monitoring.mcp_history_title": {
"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "Lịch sử gọi MCP"},
"monitoring.action_logs_title": {
"en": "Action Logs", "ja": "アクションログ", "vi": "Nhật ký hành động"},
"monitoring.col_detail_block": {
"en": "Block detail", "ja": "ブロック詳細", "vi": "Chi tiết chặn"},
# ---- event-detail panel (ui-audit_v2.html openDetail()) --------------
"monitoring.detail_section_general": {
"en": "General info", "ja": "基本情報", "vi": "Thông tin chung"},
"monitoring.detail_section_action": {
"en": "Action", "ja": "アクション", "vi": "Hành động"},
"monitoring.detail_section_metadata": {
"en": "Metadata", "ja": "メタデータ", "vi": "Metadata"},
"monitoring.detail_type": {"en": "Type", "ja": "種類", "vi": "Loại"},
"monitoring.detail_status": {"en": "Status", "ja": "ステータス", "vi": "Trạng thái"},
"monitoring.detail_event_id": {"en": "Event ID", "ja": "イベントID", "vi": "Event ID"},
"monitoring.detail_policy": {"en": "Policy", "ja": "ポリシー", "vi": "Policy"},
"monitoring.detail_severity": {"en": "Severity", "ja": "重大度", "vi": "Severity"},
"monitoring.detail_copy": {"en": "Copy", "ja": "コピー", "vi": "Copy"},
"monitoring.detail_copied": {"en": "Copied", "ja": "コピー済み", "vi": "Đã copy"},
# Trạng thái pill — which rule fired, phrased as the enforcement outcome
# (distinct wording from the Loại/action_* labels below, matching
# ui-audit_v2.html's statusInfo() vs actionLabel).
"monitoring.status_blocked": {"en": "Blocked", "ja": "ブロック済み", "vi": "Đã chặn"},
"monitoring.status_path": {"en": "Path blocked", "ja": "パスをブロック", "vi": "Path chặn"},
"monitoring.status_network": {"en": "Network blocked", "ja": "ネットワークをブロック", "vi": "Mạng chặn"},
"monitoring.status_secret": {"en": "Secret leaked", "ja": "シークレット漏洩", "vi": "Bí mật lộ"},
"monitoring.status_ok": {"en": "Succeeded", "ja": "成功", "vi": "Thành công"},
"monitoring.status_failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"},
"monitoring.severity_critical": {"en": "CRITICAL", "ja": "CRITICAL", "vi": "CRITICAL"},
"monitoring.severity_medium": {"en": "MEDIUM", "ja": "MEDIUM", "vi": "MEDIUM"},
"monitoring.severity_info": {"en": "INFO", "ja": "INFO", "vi": "INFO"},
# Loại field — a human label for the raw event name (audit_log ``name``).
"monitoring.action_prompt": {"en": "Risky prompt", "ja": "危険なプロンプト", "vi": "Prompt rủi ro"},
"monitoring.action_dangerous_command": {
"en": "Dangerous command", "ja": "危険なコマンド", "vi": "Lệnh nguy hiểm"},
"monitoring.action_install_package": {
"en": "Package install", "ja": "パッケージインストール", "vi": "Cài đặt gói"},
"monitoring.action_path_outside_sandbox": {
"en": "Path outside sandbox", "ja": "サンドボックス外のパス", "vi": "Path ngoài sandbox"},
"monitoring.action_network_blocked": {
"en": "Network blocked", "ja": "ネットワークブロック", "vi": "Mạng bị chặn"},
"monitoring.action_secret_in_output": {
"en": "Secret disclosed", "ja": "シークレット漏洩", "vi": "Tiết lộ bí mật"},
"monitoring.overview_activity_title": {
"en": "Recent log", "ja": "最近のログ", "vi": "Nhật ký gần đây"},
"monitoring.overview_no_activity": {
"en": "No activity yet.", "ja": "まだアクティビティはありません。", "vi": "Chưa có hoạt động nào."},
"monitoring.overview_resource_title": {
"en": "Resources", "ja": "リソース", "vi": "Tài nguyên"},
"monitoring.overview_res_cpu": {"en": "CPU", "ja": "CPU", "vi": "CPU"},
"monitoring.overview_res_mem": {"en": "Memory", "ja": "メモリ", "vi": "Bộ nhớ"},
"monitoring.overview_res_disk": {"en": "Disk I/O", "ja": "ディスク I/O", "vi": "Disk I/O"},
"monitoring.overview_res_network": {"en": "Network", "ja": "ネットワーク", "vi": "Mạng"},
# ---- model pricing list (Overview, beside the resource group) ----
"monitoring.pricing_title": {"en": "Model pricing", "ja": "モデル料金", "vi": "Bảng giá model"},
"monitoring.pricing_currency": {"en": "Currency", "ja": "通貨", "vi": "Tiền tệ"},
"monitoring.pricing_import": {"en": "Import", "ja": "取込", "vi": "Nhập"},
"monitoring.pricing_export": {"en": "Template", "ja": "テンプレート", "vi": "Mẫu"},
"monitoring.pricing_add": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"monitoring.pricing_autolink": {"en": "Auto-link", "ja": "自動取得", "vi": "Tự lấy"},
"monitoring.pricing_delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"monitoring.pricing_add_prompt": {"en": "Model name:", "ja": "モデル名:", "vi": "Tên model:"},
"monitoring.pricing_imported": {"en": "Imported {n} model prices.", "ja": "{n} 件の料金を取込。",
"vi": "Đã nhập {n} dòng giá."},
"monitoring.pricing_exported": {"en": "Price template exported.", "ja": "料金テンプレートを出力。",
"vi": "Đã xuất mẫu bảng giá."},
"monitoring.pricing_linked": {"en": "Linked {n} models from providers.",
"ja": "プロバイダから {n} モデルを取得。",
"vi": "Đã lấy {n} model từ provider."},
"monitoring.pricing_col_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
"monitoring.pricing_col_context": {"en": "Context", "ja": "コンテキスト", "vi": "Context"},
"monitoring.pricing_col_maxout": {"en": "Max output", "ja": "最大出力", "vi": "Max output"},
"monitoring.pricing_col_input": {"en": "Input price", "ja": "入力単価", "vi": "Giá input"},
"monitoring.pricing_col_output": {"en": "Output price", "ja": "出力単価", "vi": "Giá output"},
"monitoring.overview_sandbox_details_title": {
# One section now, holding both the sandbox facts and the permissions.
"en": "Sandbox & Permissions", "ja": "サンドボックスと権限",
"vi": "Sandbox & Quyền"},
}
+342
View File
@@ -0,0 +1,342 @@
"""Chuỗi hiển thị — phần composer.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"chatpanel.agent_tooltip": {
"en": "Model/agent for THIS tab — independent of the other tab",
"ja": "このタブ専用のモデル/エージェント(他のタブとは独立)",
"vi": "Model/agent riêng cho tab này — độc lập với tab kia"},
"chatpanel.agent_list_error": {
"en": "Could not load the model list: {err}", "ja": "モデル一覧を読み込めませんでした: {err}",
"vi": "Không tải được danh sách model: {err}"},
"chatpanel.compress_btn": {"en": "Compress", "ja": "圧縮", "vi": "Nén"},
"chatpanel.compress_tooltip": {
"en": "Compress the conversation: trim old history to cut tokens (avoid exceeding the context limit)",
"ja": "会話を圧縮:古い履歴を減らしてトークンを削減(コンテキスト上限超過を回避)",
"vi": "Nén hội thoại: bỏ bớt lịch sử cũ để giảm token (tránh lỗi vượt giới hạn context)"},
"chatpanel.files_header": {"en": "Files", "ja": "ファイル", "vi": "Files"},
"chatpanel.collapse_files_tooltip": {
"en": "Collapse the Files panel", "ja": "ファイルパネルを折りたたむ", "vi": "Thu gọn bảng Files"},
"chatpanel.expand_files_tooltip": {
"en": "Click to expand the Files panel", "ja": "クリックしてファイルパネルを展開",
"vi": "Bấm để mở lại bảng Files"},
"chatpanel.compress_busy": {
"en": "Running — stop or wait before compressing.", "ja": "実行中です。停止するか完了を待ってから圧縮してください。",
"vi": "Đang chạy — dừng hoặc đợi xong rồi hãy nén."},
"chatpanel.compress_short": {
"en": "Conversation is already short — no need to compress.",
"ja": "会話はすでに短いため圧縮の必要はありません。",
"vi": "Hội thoại đã ngắn — không cần nén."},
"chatpanel.compress_done": {
"en": "Compressed: dropped {cut} old messages, kept the last {keep} turns to cut tokens.",
"ja": "圧縮しました:古いメッセージ{cut}件を削除し、直近{keep}ターンを保持してトークンを削減。",
"vi": "Đã nén hội thoại: bỏ {cut} tin cũ, giữ {keep} lượt gần nhất để giảm token."},
"chatpanel.compress_reduced": {
"en": "Compressed to {pct}% of the original ({n} old messages digested).",
"ja": "元の {pct}% まで圧縮(古いメッセージ {n} 件を要約)。",
"vi": "Đã nén còn {pct}% so với ban đầu ({n} tin cũ được tóm gọn)."},
"chatpanel.compress_digest_header": {
"en": "Compressed summary of {n} earlier messages",
"ja": "以前のメッセージ {n} 件の要約",
"vi": "Tóm tắt nén của {n} tin nhắn trước đó"},
"chatpanel.delete_confirm_title": {"en": "Delete message", "ja": "メッセージを削除", "vi": "Xóa tin nhắn"},
"chatpanel.delete_confirm_files": {
"en": "Delete this message and its {n} input/output file(s)?\n\n{preview}",
"ja": "このメッセージと入出力ファイル{n}件を削除しますか?\n\n{preview}",
"vi": "Xóa tin nhắn này và {n} tệp input/output của nó?\n\n{preview}"},
"chatpanel.delete_confirm_plain": {"en": "Delete this message?", "ja": "このメッセージを削除しますか?", "vi": "Xóa tin nhắn này?"},
"chatpanel.delete_done": {
"en": "Message and its files deleted.", "ja": "メッセージとファイルを削除しました。",
"vi": "Đã xóa tin nhắn và các tệp liên quan."},
"chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"},
"chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
"chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."},
"chatpanel.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"},
"chatpanel.attach_limit": {
"en": "Max {n} attachments — extra files were skipped.",
"ja": "添付は最大{n}件です。超過分はスキップされました。",
"vi": "Tối đa {n} tệp đính kèm — bỏ qua phần dư."},
"chatpanel.attached_hint": {"en": "Attached: {names}", "ja": "添付: {names}", "vi": "Đã đính kèm: {names}"},
"chatpanel.skills_updated": {"en": "Skills updated.", "ja": "スキルを更新しました。", "vi": "Đã cập nhật skill."},
"chatpanel.new_files_detected": {
"en": "New file(s) detected in output folder: {names} ({n} file(s)). They will be auto-loaded as input on the next message.",
"ja": "出力フォルダに新しいファイルを検出しました: {names} ({n}ファイル)。次のメッセージで自動的に入力として読み込まれます。",
"vi": "Phát hiện tệp mới trong thư mục đầu ra: {names} ({n} tệp). Chúng sẽ được tự động tải làm dữ liệu đầu vào ở tin nhắn tiếp theo.",
},
# ---- composer.py -----------------------------------------------
"composer.placeholder_default": {
"en": "Type a message… (Enter to send, Shift+Enter for newline)",
"ja": "メッセージを入力…(Enterで送信、Shift+Enterで改行)",
"vi": "Nhập tin nhắn… (Enter để gửi, Shift+Enter xuống dòng)"},
"composer.placeholder_cowork": {
"en": "Type a request or attach a file to process… (Enter to send)",
"ja": "依頼内容を入力するかファイルを添付…(Enterで送信)",
"vi": "Nhập yêu cầu hoặc đính kèm tệp để xử lí… (Enter để gửi)"},
"composer.placeholder_code": {
"en": "Assign a task to the Code agent… (Enter to send)",
"ja": "Code エージェントにタスクを指示…(Enterで送信)",
"vi": "Giao việc cho Code agent… (Enter để gửi)"},
"composer.queue_label": {"en": "Queue ({n})", "ja": "キュー ({n})", "vi": "Hàng đợi ({n})"},
"composer.queue_tooltip": {
"en": "Double-click to remove a queued message", "ja": "ダブルクリックでキューから削除",
"vi": "Nhấp đúp để xoá một tin nhắn khỏi hàng đợi"},
"composer.attachments_label": {"en": "Attachments ({n})", "ja": "添付ファイル ({n})", "vi": "Tệp đính kèm ({n})"},
"composer.attachments_tooltip": {
"en": "Click on a chip to remove a file added by mistake",
"ja": "誤って追加したファイルは で削除できます",
"vi": "Bấm trên thẻ để gỡ tệp đính kèm nhầm"},
"composer.remove_tooltip": {
"en": "Remove this file (added by mistake)", "ja": "このファイルを削除(誤って追加)",
"vi": "Gỡ tệp này (đính kèm nhầm)"},
"composer.attach_btn_tooltip": {
"en": "Attach images or files (you can also paste or drag them in)",
"ja": "画像やファイルを添付(貼り付け・ドラッグも可)",
"vi": "Đính kèm ảnh hoặc tệp (có thể dán hoặc kéo-thả vào)"},
"composer.send": {"en": "Send", "ja": "送信", "vi": "Gửi"},
"composer.queue_btn": {"en": "Queue", "ja": "キューに追加", "vi": "Thêm vào hàng đợi"},
"composer.stop": {"en": "Stop", "ja": "停止", "vi": "Dừng"},
"composer.attach_dialog_title": {"en": "Attach files / images", "ja": "ファイル/画像を添付", "vi": "Đính kèm tệp / ảnh"},
"composer.attach_dialog_filter": {
"en": "Files (*.*);;Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp)",
"ja": "ファイル (*.*);;画像 (*.png *.jpg *.jpeg *.gif *.bmp *.webp)",
"vi": "Tệp (*.*);;Ảnh (*.png *.jpg *.jpeg *.gif *.bmp *.webp)"},
"composer.no_skills": {"en": " (no skills yet)", "ja": " (スキルはまだありません)", "vi": " (chưa có skill nào)"},
"composer.no_agents": {"en": " (no agents found)", "ja": " (エージェントが見つかりません)", "vi": " (không tìm thấy agent)"},
"composer.manage_skills": {"en": "Manage skills…", "ja": "スキルを管理…", "vi": "Quản lý skill…"},
# ---- schedule_task_tab.py / task_editor_dialog.py -------------------
"schedtask.title": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
"schedtask.view.kanban": {"en": "Kanban", "ja": "Kanban", "vi": "Kanban"},
"schedtask.view.calendar": {"en": "Calendar", "ja": "カレンダー", "vi": "Lịch"},
"schedtask.no_title": {"en": "(untitled)", "ja": "(無題)", "vi": "(chưa có tên)"},
"schedtask.cal_today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"},
"schedtask.cal_prev": {"en": "Previous", "ja": "前へ", "vi": "Trước"},
"schedtask.cal_next": {"en": "Next", "ja": "次へ", "vi": "Sau"},
"schedtask.cal_gran.week": {"en": "Week", "ja": "週", "vi": "Tuần"},
"schedtask.cal_gran.month": {"en": "Month", "ja": "月", "vi": "Tháng"},
"schedtask.cal_gran.year": {"en": "Year", "ja": "年", "vi": "Năm"},
"schedtask.cal_weekday.mon": {"en": "Mon", "ja": "月", "vi": "T2"},
"schedtask.cal_weekday.tue": {"en": "Tue", "ja": "火", "vi": "T3"},
"schedtask.cal_weekday.wed": {"en": "Wed", "ja": "水", "vi": "T4"},
"schedtask.cal_weekday.thu": {"en": "Thu", "ja": "木", "vi": "T5"},
"schedtask.cal_weekday.fri": {"en": "Fri", "ja": "金", "vi": "T6"},
"schedtask.cal_weekday.sat": {"en": "Sat", "ja": "土", "vi": "T7"},
"schedtask.cal_weekday.sun": {"en": "Sun", "ja": "日", "vi": "CN"},
"schedtask.cal_month_count": {"en": "{month} — {n} task(s)", "ja": "{month} — {n} 件",
"vi": "{month} — {n} task"},
"schedtask.search_ph": {"en": "Search tasks…", "ja": "タスクを検索…", "vi": "Tìm task…"},
"schedtask.filter_all": {"en": "All types", "ja": "すべての種類", "vi": "Mọi loại"},
"schedtask.add_btn": {"en": "Add Task", "ja": "タスク追加", "vi": "Thêm Task"},
"schedtask.ai_btn": {"en": "AI Create Task", "ja": "AIでタスク作成", "vi": "AI tạo Task"},
"schedtask.ai_tooltip": {
"en": "Describe what you want in natural language — AI proposes tasks/schedule/chain, you confirm before anything is created.",
"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."},
"schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "No tasks"},
"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_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.status.backlog": {"en": "Backlog", "ja": "Backlog", "vi": "Backlog"},
"schedtask.status.scheduled": {"en": "Scheduled", "ja": "Scheduled", "vi": "Scheduled"},
"schedtask.status.running": {"en": "Running", "ja": "Running", "vi": "Running"},
"schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "Waiting Input", "vi": "Waiting Input"},
"schedtask.status.done": {"en": "Done", "ja": "Done", "vi": "Done"},
"schedtask.status.failed": {"en": "Failed", "ja": "Failed", "vi": "Failed"},
"schedtask.status.paused": {"en": "Paused", "ja": "Paused", "vi": "Paused"},
"schedtask.type.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"schedtask.type.co4e_code": {"en": "Code", "ja": "Code", "vi": "Code"},
"schedtask.type.flow": {"en": "Flow", "ja": "Flow", "vi": "Flow"},
"schedtask.type.script": {"en": "Script", "ja": "Script", "vi": "Script"},
"schedtask.type.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
"schedtask.priority.low": {"en": "Low", "ja": "低", "vi": "Thấp"},
"schedtask.priority.medium": {"en": "Medium", "ja": "中", "vi": "Trung bình"},
"schedtask.priority.high": {"en": "High", "ja": "高", "vi": "Cao"},
"schedtask.priority.critical": {"en": "Critical", "ja": "最重要", "vi": "Khẩn cấp"},
"schedtask.menu_run": {"en": "Run now", "ja": "今すぐ実行", "vi": "Chạy ngay"},
"schedtask.menu_edit": {"en": "Edit task", "ja": "タスクを編集", "vi": "Sửa task"},
"schedtask.menu_duplicate": {"en": "Duplicate task", "ja": "タスクを複製", "vi": "Nhân bản task"},
"schedtask.menu_pause": {"en": "Pause", "ja": "一時停止", "vi": "Tạm dừng"},
"schedtask.menu_resume": {"en": "Resume", "ja": "再開", "vi": "Tiếp tục"},
"schedtask.menu_logs": {"en": "View logs", "ja": "ログを表示", "vi": "Xem log"},
"schedtask.menu_history": {"en": "Run history…", "ja": "実行履歴…", "vi": "Lịch sử chạy…"},
"schedtask.hist_hint": {
"en": "Double-click a row to open that run's artifact folder.",
"ja": "行をダブルクリックすると、その実行のフォルダを開きます。",
"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_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Run ID"},
"schedtask.hist_col_error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
"schedtask.menu_create_next": {
"en": "Create next task from output", "ja": "出力から次タスクを作成",
"vi": "Tạo task tiếp theo từ output"},
"schedtask.menu_delete": {"en": "Delete task", "ja": "タスクを削除", "vi": "Xóa task"},
"schedtask.delete_confirm": {"en": "Delete '{title}'?", "ja": "「{title}」を削除しますか?", "vi": "Xóa '{title}'?"},
"schedtask.menu_delete_selected": {"en": "Delete {n} selected", "ja": "選択した{n}件を削除", "vi": "Xóa {n} task đã chọn"},
"schedtask.delete_multi_confirm": {
"en": "Delete {n} selected tasks? This cannot be undone.",
"ja": "選択した{n}件のタスクを削除しますか?元に戻せません。",
"vi": "Xóa {n} task đã chọn? Không thể hoàn tác."},
"schedtask.msg_created": {"en": "Task created.", "ja": "タスクを作成しました。", "vi": "Đã tạo task."},
"schedtask.msg_running": {"en": "Running: {title}", "ja": "実行中: {title}", "vi": "Đang chạy: {title}"},
"schedtask.msg_manual_norun": {
"en": "Manual tasks are for tracking only — they don't execute.",
"ja": "Manualタスクは管理用のため実行されません。",
"vi": "Task Manual chỉ để quản lý — không tự chạy."},
"schedtask.msg_no_scheduler": {"en": "Scheduler not available.", "ja": "スケジューラーが利用できません。", "vi": "Scheduler chưa sẵn sàng."},
"schedtask.msg_ai_created": {"en": "Created {n} task(s) from AI plan.", "ja": "AI提案から{n}件のタスクを作成しました。", "vi": "Đã tạo {n} task từ đề xuất AI."},
"schedtask.no_runs_yet": {"en": "This task has not run yet.", "ja": "このタスクはまだ実行されていません。", "vi": "Task này chưa chạy lần nào."},
"schedtask.next_of": {"en": "Next: {title}", "ja": "次: {title}", "vi": "Tiếp theo: {title}"},
# editor
"schedtask.editor_title_new": {"en": "Add Task", "ja": "タスク追加", "vi": "Thêm Task"},
"schedtask.editor_title_edit": {"en": "Edit Task", "ja": "タスク編集", "vi": "Sửa Task"},
"schedtask.f_title": {"en": "Title", "ja": "タイトル", "vi": "Tiêu đề"},
"schedtask.f_desc": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
"schedtask.f_type": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"},
"schedtask.f_workspace": {"en": "Workspace", "ja": "ワークスペース", "vi": "Workspace"},
"schedtask.no_workspace": {"en": "— No workspace —", "ja": "— ワークスペースなし —", "vi": "— Không có workspace —"},
"schedtask.f_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"schedtask.no_agent": {"en": "— No agent preset —", "ja": "— エージェントなし —", "vi": "— Không dùng agent —"},
"schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"},
"schedtask.provider_default": {
"en": "— Default (Settings) —", "ja": "— 既定(設定)—", "vi": "— Mặc định (Settings) —"},
"schedtask.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
"schedtask.model_placeholder": {
"en": "Default model (leave blank to use Settings)",
"ja": "既定のモデル(空欄で設定を使用)",
"vi": "Model mặc định (để trống dùng Settings)"},
"schedtask.load_models_tooltip": {
"en": "Fetch this provider's available models",
"ja": "このプロバイダーの利用可能なモデルを取得",
"vi": "Tải danh sách model của provider này"},
"schedtask.load_models_empty": {
"en": "No models could be loaded. Check the provider/API key in Settings.",
"ja": "モデルを取得できませんでした。設定のプロバイダー/APIキーを確認してください。",
"vi": "Không tải được model nào. Kiểm tra provider/API key trong Settings."},
"schedtask.f_skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"},
"schedtask.no_skill": {"en": "— No skill —", "ja": "— スキルなし —", "vi": "— Không dùng skill —"},
"schedtask.hint_provider": {
"en": "Which AI provider runs this task. Leave as Default to use the machine's Settings provider.",
"ja": "このタスクを実行するAIプロバイダー。既定のままにすると設定のプロバイダーを使用します。",
"vi": "Provider AI chạy task này. Để Mặc định để dùng provider trong Settings."},
"schedtask.hint_model": {
"en": "Model to run this task. Leave blank to use the provider's Settings model; click the button to load the real list.",
"ja": "このタスクを実行するモデル。空欄で設定のモデルを使用。ボタンで実際の一覧を取得します。",
"vi": "Model chạy task này. Để trống dùng model trong Settings; bấm nút để tải danh sách thực."},
"schedtask.hint_skill": {
"en": "Apply a saved skill's instructions to this task's run (its guidance is prepended to the prompt).",
"ja": "保存済みスキルの指示をこのタスクの実行に適用します(プロンプトの先頭に追加されます)。",
"vi": "Áp dụng hướng dẫn của một skill đã lưu vào lần chạy task này (được thêm vào đầu prompt)."},
"schedtask.hint_workspace": {
"en": "The project/workspace this task's agent runs in — its sandbox folder and shared instructions apply.",
"ja": "このタスクのエージェントが実行されるプロジェクト/ワークスペース。そのサンドボックスフォルダと共有指示が適用されます。",
"vi": "Project/workspace mà agent của task này sẽ chạy trong đó — áp dụng sandbox và hướng dẫn chung của project."},
"schedtask.f_priority": {"en": "Priority", "ja": "優先度", "vi": "Độ ưu tiên"},
"schedtask.f_status": {"en": "Status", "ja": "ステータス", "vi": "Trạng thái"},
"schedtask.f_script": {"en": "Script command", "ja": "スクリプトコマンド", "vi": "Lệnh script"},
"schedtask.script_placeholder": {
"en": "(script tasks only) e.g. python report.py", "ja": "(Scriptタスクのみ)例: python report.py",
"vi": "(chỉ task Script) vd: python report.py"},
# The title/description block at the top of the Task editor had no name
# either — needed once the index had to list it.
"schedtask.g_basic": {"en": "Basics", "ja": "基本", "vi": "Thông tin chung"},
# The three steps the editor is split into: what to do, when, and what it
# connects to. Each holds the same group boxes as before.
"schedtask.step_content": {"en": "Content", "ja": "内容", "vi": "Nội dung"},
"schedtask.step_schedule": {"en": "Schedule", "ja": "スケジュール", "vi": "Lịch chạy"},
"schedtask.step_link": {"en": "Links", "ja": "連携", "vi": "Liên kết"},
"schedtask.g_schedule": {"en": "Schedule Setup", "ja": "スケジュール設定", "vi": "Thiết lập lịch chạy"},
"schedtask.sched_enable": {"en": "Enable schedule", "ja": "スケジュールを有効化", "vi": "Bật lịch chạy"},
"schedtask.f_run_at": {"en": "Run at", "ja": "実行日時", "vi": "Chạy lúc"},
"schedtask.f_repeat": {"en": "Repeat", "ja": "繰り返し", "vi": "Lặp lại"},
"schedtask.repeat.none": {"en": "None (one-time)", "ja": "なし(1回のみ)", "vi": "Không (chạy 1 lần)"},
"schedtask.repeat.daily": {"en": "Daily", "ja": "毎日", "vi": "Hằng ngày"},
"schedtask.repeat.weekly": {"en": "Weekly", "ja": "毎週", "vi": "Hằng tuần"},
"schedtask.repeat.monthly": {"en": "Monthly", "ja": "毎月", "vi": "Hằng tháng"},
"schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Cron expression"},
"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
"schedtask.f_run_kind": {"en": "Run", "ja": "実行対象", "vi": "Chạy"},
"schedtask.kind_agent": {"en": "AI agent (Cowork)", "ja": "AIエージェント(Cowork)",
"vi": "AI agent (Cowork)"},
"schedtask.kind_flow": {"en": "Co4E flow", "ja": "Co4E フロー", "vi": "Co4E flow"},
"schedtask.hint_run_kind": {
"en": "AI agent = run one Cowork agent with the chosen model. Co4E flow = run a whole "
"saved node-graph flow, step by step, in the sandbox.",
"ja": "AIエージェント=選択モデルで Cowork エージェントを1つ実行。Co4E フロー=保存済みの"
"ノードグラフ全体をサンドボックスで順に実行。",
"vi": "AI agent = chạy một agent Cowork với model đã chọn. Co4E flow = chạy cả một flow "
"node-graph đã lưu, tuần tự, trong sandbox."},
"schedtask.f_flow": {"en": "Co4E flow", "ja": "Co4E フロー", "vi": "Co4E flow"},
"schedtask.hint_flow": {
"en": "Which saved Co4E flow this task runs (built-in or your own).",
"ja": "このタスクが実行する保存済み Co4E フロー(組込み/自作)。",
"vi": "Flow Co4E đã lưu mà task này sẽ chạy (có sẵn hoặc của bạn)."},
"schedtask.flow_required": {
"en": "Pick a Co4E flow to run (or switch Run to AI agent).",
"ja": "実行する Co4E フローを選んでください(または実行対象を AI エージェントに)。",
"vi": "Hãy chọn một flow Co4E để chạy (hoặc đổi Chạy sang AI agent)."},
"schedtask.hint_task_mode": {
"en": "Normal = runs once (or manually). Automation = a cronjob that repeats on a schedule "
"(daily/weekly/monthly/cron). Switching to Automation reveals the recurrence options.",
"ja": "通常=1回(または手動)実行。自動化=スケジュールで繰り返すCronジョブ(毎日/毎週/毎月/Cron)。"
"自動化に切り替えると繰り返し設定が表示されます。",
"vi": "Thông thường = chạy một lần (hoặc thủ công). Tự động = cronjob lặp theo lịch "
"(ngày/tuần/tháng/cron). Chuyển sang Tự động sẽ hiện các tùy chọn lặp lại."},
"schedtask.mode_normal": {
"en": "Normal (one-time / manual)", "ja": "通常(1回 / 手動)",
"vi": "Thông thường (một lần / thủ công)"},
"schedtask.mode_automation": {
"en": "Automation (cron / recurring)", "ja": "自動化(Cron / 繰り返し)",
"vi": "Tự động (cronjob / lặp lại)"},
"schedtask.f_cron": {"en": "Cron", "ja": "Cron", "vi": "Cron"},
"schedtask.cron_sample_pick": {"en": "Sample ▾", "ja": "サンプル ▾", "vi": "Mẫu ▾"},
"schedtask.cron_sample_tooltip": {
"en": "Pick a ready-made schedule — it fills the cron box with correct syntax.",
"ja": "定番スケジュールを選ぶと、正しい書式でCron欄に入力されます。",
"vi": "Chọn một lịch mẫu — sẽ điền đúng cú pháp vào ô cron."},
"schedtask.cron_s_weekday9": {"en": "Weekdays 9:00", "ja": "平日 9:00", "vi": "Ngày làm việc 9:00"},
"schedtask.cron_s_daily8": {"en": "Every day 8:00", "ja": "毎日 8:00", "vi": "Mỗi ngày 8:00"},
"schedtask.cron_s_weekly_mon": {"en": "Every Monday 9:00", "ja": "毎週月曜 9:00", "vi": "Thứ 2 hằng tuần 9:00"},
"schedtask.cron_s_monthly1": {"en": "1st of month 9:00", "ja": "毎月1日 9:00", "vi": "Ngày 1 hằng tháng 9:00"},
"schedtask.cron_s_every30m": {"en": "Every 30 minutes", "ja": "30分ごと", "vi": "Mỗi 30 phút"},
"schedtask.cron_s_every2h": {"en": "Every 2 hours", "ja": "2時間ごと", "vi": "Mỗi 2 giờ"},
"schedtask.cron_placeholder": {
"en": "(repeat = Cron) e.g. 0 9 * * 1-5 — min hour day month weekday",
"ja": "(繰り返し=Cron)例: 0 9 * * 1-5 — 分 時 日 月 曜日",
"vi": "(khi lặp = Cron) vd: 0 9 * * 1-5 — phút giờ ngày tháng thứ"},
"schedtask.cron_hint": {
"en": "Only when Repeat = Cron. Fields: minute hour day-of-month month day-of-week "
"(e.g. '0 9 * * 1-5' = 9:00 every weekday). Otherwise the run-time above is the "
"daily/weekly/monthly notification time.",
"ja": "繰り返し=Cronの場合のみ。書式: 分 時 日 月 曜日(例 '0 9 * * 1-5' = 平日9:00)。"
"それ以外は上の実行時刻が毎日/毎週/毎月の通知時刻になります。",
"vi": "Chỉ khi Lặp = Cron. Cú pháp: phút giờ ngày tháng thứ (vd '0 9 * * 1-5' = 9:00 các "
"ngày trong tuần). Nếu không, giờ chạy ở trên là giờ thông báo hàng ngày/tuần/tháng."},
"schedtask.cron_invalid": {
"en": "Invalid cron expression: {err}", "ja": "Cron式が不正です: {err}",
"vi": "Cron expression không hợp lệ: {err}"},
"schedtask.cron_never_fires": {
"en": "This cron expression never fires (within 2 years).",
"ja": "このCron式は(2年以内に)一度も実行されません。",
"vi": "Cron expression này không bao giờ chạy (trong vòng 2 năm)."},
"schedtask.workdays_only": {"en": "Working days only (skip Sat/Sun)", "ja": "平日のみ(土日をスキップ)", "vi": "Chỉ ngày làm việc (bỏ T7/CN)"},
"schedtask.skip_holidays": {
"en": "Skip public holidays", "ja": "祝日をスキップ", "vi": "Bỏ qua ngày nghỉ lễ"},
"schedtask.holiday_country": {"en": "Country:", "ja": "国:", "vi": "Quốc gia:"},
"schedtask.f_notify": {"en": "Reminder", "ja": "リマインダー", "vi": "Nhắc nhở"},
"schedtask.f_notify_email": {"en": "Send to", "ja": "送信先", "vi": "Gửi tới"},
"schedtask.notify.none": {"en": "— No reminder —", "ja": "— リマインダーなし —", "vi": "— Không nhắc —"},
"schedtask.notify.teams": {"en": "Teams (webhook)", "ja": "Teams(Webhook)", "vi": "Teams (webhook)"},
"schedtask.notify.outlook": {
"en": "Email via Outlook (this PC)", "ja": "Outlookでメール(このPC)",
"vi": "Email qua Outlook (máy này)"},
}
+342
View File
@@ -0,0 +1,342 @@
"""Chuỗi hiển thị — phần cowork_tab.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"dashboard.estimated_note": {
"en": "~{pct}% of turns are estimated (~4 chars/token) — the gateway didn't report exact usage.",
"ja": "約{pct}%のターンは推定値(約4文字/トークン)です。",
"vi": "~{pct}% lượt là ước tính (~4 ký tự/token) — gateway không trả về usage chính xác."},
"dashboard.ai_analyze_btn": {"en": "AI analyze", "ja": "AI分析", "vi": "AI phân tích"},
"dashboard.ai_analyzing": {"en": "Analyzing…", "ja": "分析中…", "vi": "Đang phân tích…"},
"dashboard.ai_analyze_tooltip": {
"en": "AI reviews the aggregated numbers (never your prompt contents) and suggests how to prompt better and spend fewer tokens.",
"ja": "集計値のみをAIがレビューし(プロンプト内容は送信しません)、トークン削減のコツを提案します。",
"vi": "AI xem các con số tổng hợp (không gửi nội dung prompt) và gợi ý cách viết prompt tốt hơn, tốn ít token hơn."},
"dashboard.ai_advice_title": {
"en": "AI recommendations", "ja": "AIの提案", "vi": "Khuyến nghị từ AI"},
"dashboard.period_tooltip": {
"en": "Time range for all numbers on this page.", "ja": "このページ全体の集計期間。",
"vi": "Khoảng thời gian tính mọi con số trên trang này."},
"dashboard.source_tooltip": {
"en": "Filter by one task/session, or all.", "ja": "タスク/セッション単位で絞り込み。",
"vi": "Lọc theo 1 task/phiên, hoặc tất cả."},
"dashboard.currency_tooltip": {
"en": "Display currency (rates: fixed USD→VND/JPY, editable in config).",
"ja": "表示通貨(USD→VND/JPYの固定レート、configで変更可)。",
"vi": "Tiền tệ hiển thị (tỉ giá USD→VND/JPY cố định, sửa được trong config)."},
"dashboard.price_in_tooltip": {
"en": "USD per 1M input tokens (your gateway's price).",
"ja": "入力100万トークンあたりのUSD単価。", "vi": "USD cho 1 triệu token input (giá của gateway bạn dùng)."},
"dashboard.price_out_tooltip": {
"en": "USD per 1M output tokens.", "ja": "出力100万トークンあたりのUSD単価。",
"vi": "USD cho 1 triệu token output."},
"dashboard.price_cache_tooltip": {
"en": "USD per 1M cached tokens.", "ja": "キャッシュ100万トークンあたりのUSD単価。",
"vi": "USD cho 1 triệu token cache."},
# ---- cowork_tab.py -------------------------------------------------
"cowork.title": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"cowork.skills_btn": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
"cowork.skills_tooltip": {
"en": "Add / manage skills the agent follows (or type /skill).",
"ja": "エージェントが従うスキルを追加/管理(/skill と入力も可)。",
"vi": "Thêm/quản lý skill mà agent tuân theo (hoặc gõ /skill)."},
"cowork.new_chat": {"en": "New chat", "ja": "新しいチャット", "vi": "Cuộc trò chuyện mới"},
"cowork.assistant_title": {"en": "Internal Agent", "ja": "内部エージェント", "vi": "Internal Agent"},
"cowork.project_label": {"en": "{name}", "ja": "{name}", "vi": "{name}"},
"cowork.project_tooltip": {
"en": "This thread belongs to project “{name}” — its shared instructions and workspace apply. Manage projects in the Workspace screen.",
"ja": "このスレッドはプロジェクト「{name}」に属します — 共有指示とワークスペースが適用されます。プロジェクトはワークスペース画面で管理できます。",
"vi": "Thread này thuộc project “{name}” — instructions chung và workspace của project được áp dụng. Quản lý project trong màn hình Workspace.",
},
"cowork.pick_folder_btn": {"en": "Local folder…",
"ja": "ローカルフォルダ…",
"vi": "Thư mục Local…"},
"cowork.pick_folder_tooltip": {
"en": "Save Cowork's output directly into a folder you choose, instead of "
"auto-creating a new session folder under Output.",
"ja": "Output 配下に新しいセッションフォルダを自動作成する代わりに、選んだフォルダに直接保存します。",
"vi": "Lưu output của Cowork trực tiếp vào thư mục bạn chọn, thay vì tự tạo "
"folder phiên mới trong Output."},
"cowork.pick_folder_title": {"en": "Choose the Cowork output folder",
"ja": "Cowork の出力フォルダを選択",
"vi": "Chọn thư mục output cho Cowork"},
# ---- code_tab.py -----------------------------------------------
"code.title": {"en": "Code", "ja": "Code", "vi": "Code"},
"code.files_header": {"en": "Files", "ja": "ファイル", "vi": "Files"},
"code.collapse_file_panel": {
"en": "Collapse the file panel", "ja": "ファイルパネルを折りたたむ", "vi": "Thu gọn bảng cây thư mục"},
"code.expand_file_panel": {
"en": "Click to expand the file panel", "ja": "クリックしてファイルパネルを展開",
"vi": "Bấm để mở lại bảng cây thư mục"},
"code.local_btn": {"en": "Local…", "ja": "ローカル…", "vi": "Local…"},
"code.onedrive_btn": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
"code.onedrive_badge": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
"code.auto_run": {"en": "Auto-run", "ja": "自動実行", "vi": "Tự động"},
"code.auto_run_tooltip": {
"en": "On: agent writes files / runs commands automatically. Off: ask before each action.",
"ja": "オン:エージェントが自動でファイル書き込み/コマンド実行。オフ:毎回確認します。",
"vi": "Bật: agent tự ghi file/chạy lệnh. Tắt: hỏi xác nhận trước mỗi thao tác."},
"code.skills_tooltip": {
"en": "Add / set skills for the agent to follow (or type /skill).",
"ja": "エージェントが従うスキルを追加/設定(/skill と入力も可)。",
"vi": "Thêm/đặt skill mà agent tuân theo (hoặc gõ /skill)."},
"code.flow_chk": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"code.flow_chk_tooltip": {
"en": "Enable the predefined Req→Demo flow feature (off by default).",
"ja": "定義済みの Req→Demo フロー機能を有効化(初期値はオフ)。",
"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_tooltip": {
"en": "Build and run a multi-stage flow from requirement to demo.",
"ja": "要件からデモまでの多段フローを作成・実行します。",
"vi": "Xây dựng và chạy quy trình nhiều bước từ yêu cầu đến bản demo."},
"code.new_session": {"en": "New session", "ja": "新しいセッション", "vi": "Phiên mới"},
"code.cli_tooltip": {
"en": "Open a terminal (CLI) at the current working folder",
"ja": "現在の作業フォルダでターミナル(CLI)を開く",
"vi": "Mở CLI (terminal) tại thư mục làm việc hiện tại"},
"code.cli_not_found": {
"en": "No terminal application was found on this system.",
"ja": "このシステムにはターミナルアプリが見つかりませんでした。",
"vi": "Không tìm thấy ứng dụng terminal nào trên máy này."},
"code.skills_btn_count": {"en": "Skills ({n})", "ja": "スキル ({n})", "vi": "Skills ({n})"},
"code.assistant_title": {"en": "Code agent", "ja": "Code エージェント", "vi": "Code agent"},
"code.plan": {"en": "Plan", "ja": "プラン", "vi": "Plan"},
"code.act": {"en": "Act", "ja": "実行", "vi": "Act"},
"code.mode_toggle_tooltip": {
"en": "Plan = analyze only (no file writes). Act = execute. Auto-switches to Act on gencode.",
"ja": "Plan=分析のみ(書き込みなし)。Act=実行。コード生成指示で自動的に Act に切替。",
"vi": "Plan = chỉ phân tích (không ghi file). Act = thực thi. Tự chuyển sang Act khi phát hiện yêu cầu sinh code."},
"code.act_status": {"en": "Code: Act mode (executes).", "ja": "Code: Act モード(実行)。", "vi": "Code: chế độ Act (thực thi)."},
"code.plan_status": {"en": "Code: Plan mode (analyze only).", "ja": "Code: Plan モード(分析のみ)。", "vi": "Code: chế độ Plan (chỉ phân tích)."},
"code.cloud_sync_suffix": {"en": " — files sync to cloud", "ja": " — クラウドに同期", "vi": " — file sẽ đồng bộ lên cloud"},
"code.mode_auto_status": {"en": "Code: Auto-run mode.", "ja": "Code: 自動実行モード。", "vi": "Code: chế độ Tự động."},
"code.mode_confirm_status": {"en": "Code: Confirm mode.", "ja": "Code: 確認モード。", "vi": "Code: chế độ Xác nhận."},
"code.pick_local_title": {"en": "Choose working folder (Local)", "ja": "作業フォルダを選択(ローカル)", "vi": "Chọn thư mục làm việc (Local)"},
"code.pick_onedrive_title": {"en": "Choose a folder in OneDrive", "ja": "OneDrive 内のフォルダを選択", "vi": "Chọn thư mục trong OneDrive"},
"code.onedrive_choose_folder": {
"en": "Choose a folder in OneDrive…", "ja": "OneDrive 内のフォルダを選択…", "vi": "Chọn thư mục trong OneDrive…"},
"code.no_onedrive": {"en": "No OneDrive detected", "ja": "OneDrive が見つかりません", "vi": "Không phát hiện OneDrive"},
"code.flow_running": {
"en": "Running flow '{name}' (Act) — {n} stages.",
"ja": "フロー「{name}」を実行中(Act)— {n} ステージ。",
"vi": "Đang chạy flow '{name}' (Act) — {n} bước."},
# ---- settings_dialog.py --------------------------------------------
"settings.title": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"},
"settings.active_provider": {"en": "Active provider", "ja": "使用中のプロバイダー", "vi": "Nhà cung cấp đang dùng"},
"settings.theme": {"en": "Theme", "ja": "テーマ", "vi": "Giao diện"},
"settings.theme_dark": {"en": "Dark", "ja": "ダーク", "vi": "Tối"},
"settings.theme_light": {"en": "Light", "ja": "ライト", "vi": "Sáng"},
"settings.theme_system": {"en": "Auto (System)", "ja": "自動(システム)", "vi": "Tự động (theo hệ thống)"},
"settings.language": {"en": "Language", "ja": "言語", "vi": "Ngôn ngữ"},
"settings.tray_keep": {
"en": "Keep running in the system tray when the window is closed",
"ja": "ウィンドウを閉じてもシステムトレイで実行を継続",
"vi": "Giữ chạy nền trong khay hệ thống khi đóng cửa sổ"},
"settings.tray_notify": {
"en": "Show a tray notification when a task finishes or fails",
"ja": "タスク完了/失敗時にトレイ通知を表示",
"vi": "Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗi"},
"settings.group.openai": {"en": "OpenAI-compatible (Internal Gateway)", "ja": "OpenAI 互換(社内ゲートウェイ)", "vi": "OpenAI-compatible (Gateway nội bộ)"},
"settings.group.anthropic": {"en": "Anthropic Claude", "ja": "Anthropic Claude", "vi": "Anthropic Claude"},
# Name for the language/tray block at the top of Settings — it had none,
# because until the index existed nothing had to refer to it.
"settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"},
"settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"},
"settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"},
"settings.param_section_pricing": {
"en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"},
"settings.pricing_url_label": {
"en": "Pricing reference link", "ja": "価格表の参考リンク", "vi": "Link bảng giá tham khảo"},
"settings.pricing_url_placeholder": {
"en": "https://… (the provider's public price list)",
"ja": "https://…(プロバイダーの公開価格表)",
"vi": "https://… (trang bảng giá công khai của provider)"},
"settings.pricing_url_tooltip": {
"en": "Shown as a reference link beside the Monitoring pricing table. Prices themselves are entered by hand in that table.",
"ja": "監視画面の価格表の横に参考リンクとして表示されます。価格自体は表に手入力します。",
"vi": "Hiển thị làm link tham khảo cạnh bảng giá trong Monitoring. Giá vẫn do Admin nhập tay vào bảng."},
"settings.group.accounts": {
"en": "Shared accounts folder", "ja": "共有アカウントフォルダー", "vi": "Thư mục tài khoản dùng chung"},
"settings.accounts_dir_label": {"en": "Folder", "ja": "フォルダー", "vi": "Thư mục"},
"settings.accounts_dir_placeholder": {
"en": "OneDrive/network folder holding the shared accounts & groups",
"ja": "アカウント/グループを保存する OneDrive・共有フォルダー",
"vi": "Thư mục OneDrive/mạng chứa danh sách tài khoản & nhóm dùng chung"},
"settings.accounts_dir_hint": {
"en": "Where accounts, groups and shared telemetry live. Every machine must point at the SAME folder.",
"ja": "アカウント・グループ・共有テレメトリの保存先。全マシンで同じフォルダーを指定してください。",
"vi": "Nơi lưu tài khoản, nhóm và telemetry dùng chung. Mọi máy phải trỏ về CÙNG một thư mục."},
"settings.accounts_dir_admin_only": {
"en": "Only an Admin can change this folder.",
"ja": "このフォルダーを変更できるのは管理者のみです。",
"vi": "Chỉ Admin mới thay đổi được thư mục này."},
"settings.group.monitoring_visibility": {
"en": "Monitoring tab visibility (Sub-admin)", "ja": "モニタリングタブの表示(サブ管理者)",
"vi": "Hiển thị tab Monitoring (Sub-admin)"},
"settings.mv_security_events": {"en": "Security Events", "ja": "セキュリティイベント",
"vi": "Security Events"},
"settings.mv_mcp_history": {"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "MCP Call History"},
"settings.mv_action_logs": {"en": "Action Logs", "ja": "アクションログ", "vi": "Action Logs"},
"settings.mv_agent_status": {"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"},
"settings.mv_hint": {
"en": "Admin always sees every Monitoring tab. Turn one off here to hide it from Sub-admin too (it stays available to Admin).",
"ja": "管理者は常にすべてのタブを見られます。ここでオフにすると、そのタブはサブ管理者からも隠されます(管理者には影響しません)。",
"vi": "Admin luôn thấy mọi tab Monitoring. Tắt một mục ở đây sẽ ẩn tab đó với Sub-admin (Admin vẫn thấy như thường)."},
"settings.sec_unlock_user_placeholder": {
"en": "Admin account", "ja": "管理者アカウント", "vi": "Tài khoản admin"},
"settings.sec_unlock_code_placeholder": {
"en": "Access code", "ja": "アクセスコード", "vi": "Mã truy cập"},
"settings.sec_unlock_btn": {"en": "Unlock", "ja": "ロック解除", "vi": "Mở khóa"},
"settings.sec_locked_hint": {
"en": "Locked — enter an Admin account + access code to change these settings.",
"ja": "ロック中 — 変更するには管理者アカウントとアクセスコードを入力してください。",
"vi": "Đang khóa — nhập tài khoản Admin + mã truy cập để thay đổi các thiết lập này."},
"settings.sec_unlocked_hint": {
"en": "Unlocked — changes will be saved; the group locks again after Save.",
"ja": "ロック解除中 — 保存後に再びロックされます。",
"vi": "Đã mở khóa — thay đổi sẽ được lưu; nhóm sẽ tự khóa lại sau khi Save."},
"settings.sec_unlock_failed": {
"en": "Not an Admin account (or wrong code / accounts folder unreachable).",
"ja": "管理者アカウントではありません(またはコード誤り・フォルダー未接続)。",
"vi": "Không phải tài khoản Admin (hoặc sai mã / không truy cập được thư mục tài khoản)."},
"settings.sec_no_lock_hint": {
"en": "No shared accounts folder configured yet — the group is editable without an admin unlock.",
"ja": "共有アカウントフォルダー未設定のため、ロックなしで編集できます。",
"vi": "Chưa cấu hình thư mục tài khoản dùng chung — nhóm này đang chỉnh sửa được mà không cần mở khóa."},
"settings.base_url": {"en": "Base URL", "ja": "ベース URL", "vi": "Base URL"},
"settings.api_key": {"en": "API Key", "ja": "API キー", "vi": "API Key"},
"settings.model": {"en": "Model", "ja": "モデル", "vi": "Model"},
"settings.load": {"en": "Load", "ja": "読み込み", "vi": "Tải"},
"settings.load_tooltip": {
"en": "Fetch the available models/agents from this provider",
"ja": "このプロバイダーから利用可能なモデル/エージェントを取得",
"vi": "Lấy danh sách model/agent khả dụng từ nhà cung cấp này"},
"settings.group.teams": {"en": "Microsoft Teams", "ja": "Microsoft Teams", "vi": "Microsoft Teams"},
"settings.teams_webhook": {"en": "Webhook URL", "ja": "Webhook URL", "vi": "Webhook URL"},
"settings.teams_webhook_placeholder": {
"en": "https://… (Workflows or Incoming Webhook URL)",
"ja": "https://…(Workflows または Incoming Webhook の URL)",
"vi": "https://… (URL của Workflows hoặc Incoming Webhook)"},
"settings.teams_test": {"en": "Test", "ja": "テスト", "vi": "Kiểm tra"},
"settings.teams_notify": {
"en": "Auto-send to Teams when a task completes", "ja": "タスク完了時に Teams へ自動送信",
"vi": "Tự động gửi sang Teams khi tác vụ hoàn thành"},
"settings.teams_hint": {
"en": ("Get a webhook: Teams channel → ⋯ → Connectors → Incoming Webhook, "
"OR Power Automate → 'When a HTTP request is received' → 'Post message in a chat or channel'. "
"URL must contain logic.azure.com or webhook.office.com."),
"ja": ("Webhook の取得: Teams チャンネル → ⋯ → コネクタ → Incoming Webhook、"
"または Power Automate → 'HTTP要求の受信時' → 'チャットまたはチャネルにメッセージを投稿'。"
"URL には logic.azure.com か webhook.office.com を含める必要があります。"),
"vi": ("Lấy webhook: kênh Teams → ⋯ → Connectors → Incoming Webhook, "
"HOẶC Power Automate → 'When a HTTP request is received' → 'Post message in a chat or channel'. "
"URL phải chứa logic.azure.com hoặc webhook.office.com.")},
"settings.group.ms365": {
"en": "Microsoft 365 connections", "ja": "Microsoft 365 連携",
"vi": "Kết nối Microsoft 365"},
"settings.ms365_unlock_code": {"en": "Unlock code", "ja": "解除コード", "vi": "Mã mở khóa"},
"settings.ms365_unlock_placeholder": {
"en": "Enter the unlock code", "ja": "解除コードを入力",
"vi": "Nhập mã để mở khóa"},
"settings.ms365_unlock_btn": {"en": "Unlock", "ja": "解除", "vi": "Mở khóa"},
"settings.ms365_locked_hint": {
"en": "Locked — enter the unlock code above to edit this section.",
"ja": "ロック中 — このセクションを編集するには上の解除コードを入力してください。",
"vi": "Đang khóa — nhập mã ở trên để chỉnh sửa mục này."},
"settings.ms365_unlocked_hint": {
"en": "Unlocked — remember to click Save; this section re-locks automatically afterward.",
"ja": "解除しました — 保存を忘れずに。保存後は自動的に再ロックされます。",
"vi": "Đã mở khóa — nhớ bấm Save; mục này sẽ tự khóa lại ngay sau đó."},
"settings.ms365_wrong_code": {
"en": "Wrong code.", "ja": "コードが違います。", "vi": "Mã không đúng."},
"settings.ms365_connector.outlook": {"en": "Outlook", "ja": "Outlook", "vi": "Outlook"},
"settings.ms365_connector.teams": {"en": "Teams", "ja": "Teams", "vi": "Teams"},
"settings.ms365_connector.onedrive": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
"settings.ms365_connector.sharepoint": {"en": "SharePoint", "ja": "SharePoint", "vi": "SharePoint"},
"settings.ms365_connector.meeting_transcript": {
"en": "Meeting transcript", "ja": "会議の文字起こし", "vi": "Meeting transcript"},
"settings.ms365_allow_internet": {
"en": "Allow external internet access", "ja": "外部インターネットアクセスを許可",
"vi": "Cho phép truy cập Internet bên ngoài"},
"settings.ms365_internet_off_hint": {
"en": ("External internet access is OFF — every connector was turned off to avoid "
"leaking data outside. Turn it back on, then re-tick the connectors you want."),
"ja": ("外部インターネットアクセスがオフです — データが外部に漏れないよう、すべてのコネクタ"
"がオフになりました。再度オンにしてから、必要なコネクタを選び直してください。"),
"vi": ("Đã tắt truy cập Internet bên ngoài — mọi connector đã tự tắt để tránh rò rỉ "
"thông tin ra ngoài. Bật lại rồi tick lại từng connector muốn dùng.")},
"settings.ms365_signin_btn": {"en": "Sign in with Microsoft", "ja": "Microsoft でサインイン",
"vi": "Đăng nhập Microsoft"},
"settings.ms365_signout_btn": {"en": "Sign out", "ja": "サインアウト", "vi": "Đăng xuất"},
"settings.ms365_not_signed_in": {
"en": "Not signed in to Microsoft 365.", "ja": "Microsoft 365 にサインインしていません。",
"vi": "Chưa đăng nhập Microsoft 365."},
"settings.ms365_signed_in_as": {
"en": "Signed in as {user}", "ja": "{user} としてサインイン中",
"vi": "Đã đăng nhập với {user}"},
"settings.ms365_missing_ids": {
"en": "Enter the Tenant ID and Client ID first.", "ja": "先に Tenant ID と Client ID を入力してください。",
"vi": "Hãy nhập Tenant ID và Client ID trước."},
"settings.ms365_signing_in": {
"en": "Starting sign-in…", "ja": "サインインを開始しています…", "vi": "Đang bắt đầu đăng nhập…"},
"settings.ms365_signin_failed": {
"en": "Sign-in failed: {err}", "ja": "サインインに失敗しました: {err}",
"vi": "Đăng nhập thất bại: {err}"},
"settings.ms365_teams_link_label": {
"en": "Or just paste a Teams channel/chat link — no ID needed:",
"ja": "または Teams のチャネル/チャットのリンクを貼り付けるだけ — ID は不要です:",
"vi": "Hoặc chỉ cần paste link kênh/chat Teams — không cần ID:"},
"settings.ms365_teams_link_placeholder": {
"en": "Paste a link from Teams ('Get link to channel' or a message's 'Copy link')",
"ja": "Teams のリンクを貼り付け(「チャネルへのリンクを取得」またはメッセージの「リンクをコピー」)",
"vi": "Dán link từ Teams ('Get link to channel' hoặc 'Copy link' của 1 tin nhắn)"},
"settings.ms365_teams_connect_btn": {"en": "Connect", "ja": "接続", "vi": "Kết nối"},
"settings.ms365_teams_not_connected": {
"en": "No Teams chat/channel connected yet.", "ja": "Teams のチャット/チャネルはまだ接続されていません。",
"vi": "Chưa kết nối chat/kênh Teams nào."},
"settings.ms365_teams_connected_channel": {
"en": "Connected to a Teams channel.", "ja": "Teams のチャネルに接続済みです。",
"vi": "Đã kết nối vào một kênh Teams."},
"settings.ms365_teams_connected_chat": {
"en": "Connected to a Teams chat.", "ja": "Teams のチャットに接続済みです。",
"vi": "Đã kết nối vào một đoạn chat Teams."},
"settings.ms365_teams_link_missing": {
"en": "Paste a Teams link first.", "ja": "先に Teams のリンクを貼り付けてください。",
"vi": "Hãy dán link Teams trước."},
"settings.ms365_teams_connecting": {
"en": "Connecting…", "ja": "接続しています…", "vi": "Đang kết nối…"},
"settings.ms365_teams_connect_failed": {
"en": "Connect failed: {err}", "ja": "接続に失敗しました: {err}",
"vi": "Kết nối thất bại: {err}"},
"settings.ms365_teams_intro_message": {
"en": "Hi, I'm the Cowork agent — just connected to this chat/channel.",
"ja": "こんにちは、Cowork エージェントです — このチャット/チャネルに接続しました。",
"vi": "Xin chào, mình là Cowork agent — vừa kết nối vào chat/kênh này."},
"settings.group.agent_security": {
"en": "Agent Security (AI)", "ja": "エージェント セキュリティ(AI)",
"vi": "Agent Security (AI)"},
"settings.group.sandbox": {
"en": "Sandbox Security Layer", "ja": "サンドボックス セキュリティ層",
"vi": "Sandbox Security Layer"},
"settings.sandbox_confirm_commands": {
"en": "Confirm before Cowork runs a command",
"ja": "Cowork がコマンドを実行する前に確認する",
"vi": "Xác nhận trước khi Cowork chạy lệnh"},
"settings.sandbox_confirm_commands_tooltip": {
"en": ("Shows an Approve/Reject dialog before run_command/install_package "
"executes in Cowork — off by default (auto-run), same as before."),
"ja": "Cowork で run_command/install_package を実行する前に承認/拒否ダイアログを表示します — "
"デフォルトはオフ(自動実行)で、これまでと同じです。",
"vi": "Hiện hộp thoại Duyệt/Từ chối trước khi Cowork chạy run_command/install_package — "
"mặc định tắt (tự chạy), giống hành vi cũ."},
}
+342
View File
@@ -0,0 +1,342 @@
"""Chuỗi hiển thị — phần hint.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.notify_email_placeholder": {
"en": "recipient@example.com (comma-separated)",
"ja": "recipient@example.com(カンマ区切り)",
"vi": "nguoinhan@example.com (cách nhau dấu phẩy)"},
"schedtask.notify_hint": {
"en": "When the scheduled/cron task finishes, send a reminder. Teams uses the webhook "
"from Settings; Outlook sends from your signed-in Outlook desktop app — no login needed.",
"ja": "スケジュール/Cronタスク完了時にリマインダーを送信。Teamsは設定のWebhookを使用、"
"Outlookはサインイン済みのOutlookデスクトップから送信(ログイン不要)。",
"vi": "Khi task theo lịch/cron chạy xong sẽ gửi nhắc. Teams dùng webhook trong Settings; "
"Outlook gửi từ ứng dụng Outlook đã đăng nhập trên máy — không cần đăng nhập lại."},
"schedtask.notify_need_email": {
"en": "Enter a recipient address for the Outlook reminder.",
"ja": "Outlookリマインダーの送信先アドレスを入力してください。",
"vi": "Hãy nhập địa chỉ người nhận cho nhắc nhở qua Outlook."},
"schedtask.notify_need_webhook": {
"en": "Teams reminder needs a webhook URL — set it in Settings → Parameter first.",
"ja": "TeamsリマインダーにはWebhook URLが必要です。先に設定→パラメータで設定してください。",
"vi": "Nhắc qua Teams cần webhook URL — hãy đặt trong Settings → Parameter trước."},
"schedtask.tz_local_note": {
"en": "Times use this machine's local timezone.", "ja": "時刻はこのPCのローカルタイムゾーンです。",
"vi": "Giờ dùng múi giờ local của máy này."},
"schedtask.g_flow": {"en": "Flow Setup", "ja": "フロー設定", "vi": "Thiết lập Flow"},
"schedtask.flow_hint": {
"en": "(Flow tasks only) Steps run in order; each step's output feeds the next step's input.",
"ja": "(Flowタスクのみ)ステップは順番に実行され、前ステップの出力が次の入力になります。",
"vi": "(Chỉ task Flow) Các bước chạy tuần tự; output bước trước nối vào input bước sau."},
"schedtask.flow_template": {"en": "Code template:", "ja": "Codeテンプレート:", "vi": "Template Code:"},
"schedtask.import_flow_btn": {"en": "Import steps", "ja": "ステップ取込", "vi": "Nhập các bước"},
"schedtask.flow_template_empty": {
"en": "The selected template has no steps.", "ja": "選択したテンプレートにステップがありません。",
"vi": "Template đã chọn không có bước nào."},
"schedtask.step_name_ph": {"en": "Step name", "ja": "ステップ名", "vi": "Tên bước"},
"schedtask.step_prompt_ph": {"en": "Prompt / command", "ja": "プロンプト/コマンド", "vi": "Prompt / lệnh"},
"schedtask.stepexec.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"schedtask.stepexec.co4e": {"en": "Code", "ja": "Code", "vi": "Code"},
"schedtask.stepexec.script": {"en": "Script", "ja": "Script", "vi": "Script"},
"schedtask.stepexec.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
"schedtask.del_step_tooltip": {"en": "Delete the selected step", "ja": "選択したステップを削除",
"vi": "Xóa bước đang chọn"},
"schedtask.guide_tooltip": {
"en": "Open the Schedule Task user guide", "ja": "Schedule Task の使い方ガイドを開く",
"vi": "Mở hướng dẫn sử dụng Schedule Task"},
"schedtask.guide_missing": {
"en": "Guide file not found (docs/schedule_task_user_guide.md).",
"ja": "ガイドファイルが見つかりません (docs/schedule_task_user_guide.md)。",
"vi": "Không tìm thấy file hướng dẫn (docs/schedule_task_user_guide.md)."},
"schedtask.msg_set_schedule": {
"en": "Set a run time so this task can actually run on schedule.",
"ja": "実行時刻を設定するとスケジュール実行されます。",
"vi": "Hãy đặt giờ chạy để task này thực sự chạy theo lịch."},
# ---- hint tooltips (hover help) --------------------------------------
"schedtask.add_tooltip": {
"en": "Create a new task with full options (schedule, input, dependencies…).",
"ja": "新しいタスクを作成(スケジュール・入力・依存など全設定)。",
"vi": "Tạo task mới với đầy đủ tuỳ chọn (lịch, input, phụ thuộc…)."},
"schedtask.search_tooltip": {
"en": "Filter cards by title/description.", "ja": "タイトル/説明でカードを絞り込み。",
"vi": "Lọc card theo tiêu đề/mô tả."},
"schedtask.filter_tooltip": {
"en": "Show only one task type.", "ja": "1つのタスク種別のみ表示。",
"vi": "Chỉ hiện một loại task."},
"schedtask.col_tip.backlog": {
"en": "New tasks with no schedule yet. Drag a card here to shelve it.",
"ja": "未スケジュールの新規タスク。", "vi": "Task mới, chưa đặt lịch. Kéo card vào đây để cất lại."},
"schedtask.col_tip.scheduled": {
"en": "On the calendar — runs automatically at its time. Drop a card here to schedule it.",
"ja": "スケジュール済み — 時刻になると自動実行。", "vi": "Đã lên lịch — tự chạy khi đến giờ. Thả card vào đây để đặt lịch."},
"schedtask.col_tip.running": {
"en": "Currently executing. Drop a card here to RUN it immediately.",
"ja": "実行中。ここにドロップすると即実行します。", "vi": "Đang chạy. Thả card vào đây để CHẠY NGAY."},
"schedtask.col_tip.waiting_input": {
"en": "Waiting: needs your Run-now approval, or its prerequisite tasks aren't Done yet.",
"ja": "待機中: 手動承認待ち、または前提タスクが未完了。",
"vi": "Đang chờ: cần bạn bấm Chạy ngay (phê duyệt), hoặc các task phụ thuộc chưa Done."},
"schedtask.col_tip.done": {
"en": "Finished successfully. Drop a card here to mark it done by hand.",
"ja": "完了。ここにドロップすると手動で完了扱いにします。",
"vi": "Đã xong. Thả card vào đây để tự đánh dấu hoàn thành."},
"schedtask.col_tip.failed": {
"en": "Last run errored — right-click → Run history to see why.",
"ja": "前回失敗 — 右クリック→実行履歴で原因を確認。",
"vi": "Lần chạy cuối bị lỗi — chuột phải → Lịch sử chạy để xem lý do."},
"schedtask.col_tip.paused": {
"en": "Paused: never auto-runs and is skipped by chains until resumed.",
"ja": "一時停止中: 再開まで自動実行されず、チェーンでもスキップされます。",
"vi": "Tạm dừng: không tự chạy và bị chuỗi bỏ qua cho tới khi tiếp tục."},
"schedtask.hint_type": {
"en": "Cowork = documents/answers · Code = coding agent · Script = shell command · Flow = multi-step · Manual = tracking only.",
"ja": "Cowork=文書/回答 · Code=コーディング · Script=コマンド · Flow=複数ステップ · Manual=管理のみ。",
"vi": "Cowork = tài liệu/trả lời · Code = agent code · Script = lệnh shell · Flow = nhiều bước · Manual = chỉ quản lý."},
"schedtask.hint_status": {
"en": "Current Kanban lane. Usually managed automatically by the scheduler.",
"ja": "現在のKanbanレーン。通常はスケジューラーが自動管理。",
"vi": "Cột Kanban hiện tại. Thường được scheduler tự quản lý."},
"schedtask.hint_script": {
"en": "Shell command to run (Script tasks). Runs in the task's artifact folder with a timeout.",
"ja": "実行するシェルコマンド(Scriptタスク)。", "vi": "Lệnh shell sẽ chạy (task Script), trong thư mục artifact riêng, có timeout."},
"schedtask.hint_sched_enable": {
"en": "Off = the task never runs by itself.", "ja": "OFF = 自動実行されません。",
"vi": "Tắt = task không bao giờ tự chạy."},
"schedtask.hint_run_at": {
"en": "First/next run time (this machine's local time).",
"ja": "初回/次回の実行時刻(ローカル時刻)。", "vi": "Giờ chạy đầu/kế tiếp (giờ local của máy)."},
"schedtask.hint_repeat": {
"en": "After a successful run, the schedule rolls to the next occurrence automatically.",
"ja": "成功後、次回分へ自動的に繰り越します。",
"vi": "Sau khi chạy thành công, lịch tự dời sang kỳ kế tiếp."},
"schedtask.hint_cron": {
"en": "5 fields: minute hour day month weekday. E.g. '0 9 * * 1-5' = 9:00 on weekdays.",
"ja": "5項目: 分 時 日 月 曜日。例 '0 9 * * 1-5' = 平日9時。",
"vi": "5 trường: phút giờ ngày tháng thứ. VD '0 9 * * 1-5' = 9h các ngày thường."},
"schedtask.hint_workdays": {
"en": "Runs landing on Sat/Sun are pushed to the next working day.",
"ja": "土日に当たる回は翌営業日に繰り越し。", "vi": "Lịch rơi vào T7/CN sẽ dời sang ngày làm việc kế."},
"schedtask.hint_holidays": {
"en": "Runs landing on a public holiday of the chosen country are pushed to the next allowed day.",
"ja": "選択した国の祝日に当たる回は翌営業日に繰り越し。",
"vi": "Lịch rơi vào ngày lễ của quốc gia đã chọn sẽ tự dời sang ngày hợp lệ kế."},
"schedtask.hint_country": {
"en": "ISO country code for the holiday calendar (VN, JP, US… — type any code).",
"ja": "祝日カレンダーの国コード(VN, JP, US…)。", "vi": "Mã quốc gia cho lịch nghỉ lễ (VN, JP, US… — gõ được mã bất kỳ)."},
"schedtask.hint_flow_template": {
"en": "Import the stages of a saved Flow template as steps here.",
"ja": "保存済みFlowテンプレートをステップとして取り込み。",
"vi": "Nhập các stage của Flow template đã lưu thành các bước ở đây."},
"schedtask.hint_input_mode": {
"en": "What the agent receives besides the description: nothing, typed text, file contents, or the output of earlier tasks.",
"ja": "説明に加えてエージェントへ渡す入力。", "vi": "Agent nhận gì ngoài mô tả: trống, văn bản gõ tay, nội dung tệp, hoặc output các task trước."},
"schedtask.hint_prev_task": {
"en": "Single explicit source task for 'previous task output' (leave (none) to use all waited-for tasks).",
"ja": "「前タスクの出力」の明示的なソース。", "vi": "Task nguồn cụ thể cho 'output task trước' (để (không) sẽ dùng tất cả task đang chờ)."},
"schedtask.hint_output_mode": {
"en": "Expected output format — informational for now, files always land in the artifact folder.",
"ja": "想定する出力形式(参考情報)。", "vi": "Định dạng output mong muốn — hiện mang tính thông tin, file luôn nằm trong thư mục artifact."},
"schedtask.hint_next_task": {
"en": "Task to trigger after this one finishes (chain).",
"ja": "このタスク完了後に起動するタスク(チェーン)。", "vi": "Task được kích hoạt sau khi task này xong (chuỗi)."},
"schedtask.hint_run_next": {
"en": "When the next task fires: on success / always / only after you confirm.",
"ja": "次タスクの起動条件: 成功時/常に/手動確認後。", "vi": "Khi nào task sau chạy: khi thành công / luôn / chờ bạn xác nhận."},
"schedtask.hint_pass_output": {
"en": "This task's output.md becomes the next task's input automatically.",
"ja": "このタスクのoutput.mdを次タスクの入力に自動投入。",
"vi": "output.md của task này tự thành input của task sau."},
"schedtask.hint_depends": {
"en": "Fan-in: this task waits until ALL ticked tasks are Done, then runs automatically with their outputs available.",
"ja": "ファンイン: チェックした全タスクがDoneになるまで待機し、自動実行。",
"vi": "Fan-in: task này đợi TẤT CẢ task được tick Done rồi mới tự chạy, kèm output của chúng."},
"schedtask.hint_retry": {
"en": "Auto-retry this many times when a run fails.", "ja": "失敗時の自動リトライ回数。",
"vi": "Tự thử lại bấy nhiêu lần khi chạy lỗi."},
"schedtask.hint_timeout": {
"en": "Hard limit per run (Script tasks).", "ja": "1回あたりの上限時間(Script)。",
"vi": "Giới hạn thời gian mỗi lần chạy (task Script)."},
"schedtask.hint_approval": {
"en": "Safety: the scheduler will NEVER auto-run this — it parks in Waiting Input until you right-click → 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."},
"schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "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.manual": {"en": "Manual text", "ja": "手入力テキスト", "vi": "Văn bản nhập tay"},
"schedtask.inmode.file": {"en": "File(s)", "ja": "ファイル", "vi": "Tệp"},
"schedtask.inmode.previous_task_output": {
"en": "Previous task output", "ja": "前タスクの出力", "vi": "Output của task trước"},
"schedtask.f_manual_text": {"en": "Prompt", "ja": "プロンプト", "vi": "Prompt"},
"schedtask.gen_input_tooltip": {
"en": "AI-draft the prompt from the title/description", "ja": "タイトル/説明からプロンプトをAI生成",
"vi": "AI soạn prompt từ tiêu đề/mô tả"},
"schedtask.f_files": {"en": "Attach files", "ja": "添付ファイル", "vi": "Đính kèm tệp"},
"schedtask.f_links": {"en": "Attach links", "ja": "添付リンク", "vi": "Đính kèm link"},
"schedtask.files_placeholder": {
"en": "Local file paths, separated by ;", "ja": "ローカルファイルパス(;区切り)",
"vi": "Đường dẫn tệp local, cách nhau bằng ;"},
"schedtask.links_placeholder": {
"en": "https://… URLs separated by ;", "ja": "https://… URL(;区切り)",
"vi": "https://… các link, cách nhau bằng ;"},
"schedtask.pick_files": {"en": "Browse…", "ja": "参照…", "vi": "Chọn tệp…"},
"schedtask.add_link_title": {"en": "Add link", "ja": "リンクを追加", "vi": "Thêm link"},
"schedtask.add_link_label": {"en": "URL:", "ja": "URL:", "vi": "URL:"},
"schedtask.hint_files": {
"en": "Attached files are always read and given to the agent as context, regardless of Input mode.",
"ja": "添付ファイルはInputモードに関係なく常にエージェントへ渡されます。",
"vi": "Tệp đính kèm luôn được đọc và đưa vào ngữ cảnh cho agent, bất kể chế độ Input."},
"schedtask.hint_links": {
"en": "Each URL is fetched (best-effort) and its text content given to the agent as context.",
"ja": "各URLを取得し(ベストエフォート)、テキストをコンテキストとして渡します。",
"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.g_output": {"en": "Output", "ja": "出力", "vi": "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.f_next_task": {"en": "Next task", "ja": "次タスク", "vi": "Task tiếp theo"},
"schedtask.f_run_next": {"en": "Run next task", "ja": "次タスクの実行", "vi": "Chạy task tiếp theo"},
"schedtask.runnext.none": {"en": "Don't run next task", "ja": "実行しない", "vi": "Không chạy task sau"},
"schedtask.runnext.run_after_success": {
"en": "Run after success", "ja": "成功後に実行", "vi": "Chạy khi task này thành công"},
"schedtask.runnext.run_always": {"en": "Always run", "ja": "常に実行", "vi": "Luôn chạy (kể cả lỗi)"},
"schedtask.runnext.run_after_manual_confirm": {
"en": "Wait for my confirmation", "ja": "手動確認後に実行", "vi": "Chờ tôi xác nhận rồi chạy"},
"schedtask.pass_output": {
"en": "Use this task's output as next task's input",
"ja": "このタスクの出力を次タスクの入力にする",
"vi": "Dùng output task này làm input task sau"},
"schedtask.next_paused_warn": {
"en": "The selected next task is paused — it will be skipped when this task finishes.",
"ja": "選択した次タスクは一時停止中のため、完了時にスキップされます。",
"vi": "Task tiếp theo đang tạm dừng — sẽ bị bỏ qua khi task này chạy xong."},
"schedtask.none": {"en": "(none)", "ja": "(なし)", "vi": "(không)"},
"schedtask.g_execution": {"en": "Execution", "ja": "実行設定", "vi": "Thực thi"},
"schedtask.f_retry": {"en": "Max retry", "ja": "最大リトライ", "vi": "Số lần thử lại"},
"schedtask.f_timeout": {"en": "Timeout", "ja": "タイムアウト", "vi": "Thời gian tối đa"},
"schedtask.requires_approval": {
"en": "Requires approval (scheduler will NOT auto-run; waits for Run now)",
"ja": "承認必須(自動実行されず、手動のRun nowを待ちます)",
"vi": "Cần phê duyệt (không tự chạy theo lịch; chờ bấm Chạy ngay)"},
"schedtask.notify_ok": {"en": "Notify Teams on complete", "ja": "完了時にTeams通知", "vi": "Báo Teams khi xong"},
"schedtask.notify_err": {"en": "Notify Teams on error", "ja": "エラー時にTeams通知", "vi": "Báo Teams khi lỗi"},
"schedtask.title_required": {"en": "Please enter a title.", "ja": "タイトルを入力してください。", "vi": "Vui lòng nhập tiêu đề."},
# AI create dialog
"schedtask.ai_desc_label": {
"en": "Describe what you want to automate:", "ja": "自動化したい内容を記述:",
"vi": "Mô tả việc bạn muốn tự động hoá:"},
"schedtask.ai_desc_ph": {
"en": "e.g. Every Monday 9:00, use Code to read new CAE data and build a markdown report, then have Cowork draft a team email from it.",
"ja": "例: 毎週月曜9時、CodeでCAEデータを読み込みレポート作成、その後Coworkでメール下書きを作成。",
"vi": "vd: Mỗi thứ 2 lúc 9h, dùng Code đọc dữ liệu CAE mới tạo báo cáo markdown, sau đó Cowork soạn email draft gửi team."},
"schedtask.ai_generate": {"en": "Generate plan", "ja": "プランを生成", "vi": "Tạo kế hoạch"},
"schedtask.ai_generating": {"en": "Generating…", "ja": "生成中…", "vi": "Đang tạo…"},
"schedtask.ai_preview_label": {
"en": "Preview (nothing is created until you confirm):",
"ja": "プレビュー(確認するまで作成されません):",
"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.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "AI gen task"},
"schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Import"},
"schedtask.export_template_btn": {
"en": "Create Excel template…", "ja": "Excelテンプレートを作成…",
"vi": "Tạo template Excel…"},
"schedtask.import_pick_btn": {"en": "Choose file…", "ja": "ファイルを選択…", "vi": "Chọn file…"},
"schedtask.drop_hint": {
"en": "…or drag & drop the filled .xlsx here",
"ja": "…または記入済みの .xlsx をここにドラッグ&ドロップ",
"vi": "…hoặc kéo-thả file .xlsx đã điền vào đây"},
"schedtask.f_depends_on": {
"en": "Wait for tasks (all must be Done)", "ja": "待機するタスク(全てDone必須)",
"vi": "Chờ các task (tất cả phải Done)"},
"schedtask.gen_desc_tooltip": {
"en": "Generate the Prompt from this description (the title is not used)",
"ja": "この説明からプロンプトを生成(タイトルは使用しません)",
"vi": "Sinh Prompt từ mô tả này (không dùng tiêu đề)"},
"schedtask.gen_needs_description": {
"en": "Enter a description first — the Prompt is generated from it.",
"ja": "先に説明を入力してください。プロンプトは説明から生成されます。",
"vi": "Hãy nhập mô tả trước — Prompt được sinh ra từ mô tả."},
# ---- dashboard_tab.py ------------------------------------------------
"dashboard.title": {"en": "Dashboard — token usage & cost", "ja": "Dashboard — トークン使用量とコスト",
"vi": "Dashboard — token & chi phí"},
"dashboard.period.today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"},
"dashboard.period.week": {"en": "Last 7 days", "ja": "過去7日", "vi": "7 ngày qua"},
"dashboard.period.month": {"en": "Last 30 days", "ja": "過去30日", "vi": "30 ngày qua"},
"dashboard.period.all": {"en": "All time", "ja": "全期間", "vi": "Toàn bộ"},
"dashboard.source_all": {"en": "All tasks/sessions", "ja": "全タスク/セッション", "vi": "Mọi task/phiên"},
"dashboard.refresh_tooltip": {"en": "Refresh now", "ja": "今すぐ更新", "vi": "Làm mới ngay"},
"dashboard.card_total": {"en": "Total tokens", "ja": "合計トークン", "vi": "Tổng token"},
"dashboard.card_in": {"en": "Input", "ja": "入力", "vi": "Input"},
"dashboard.card_out": {"en": "Output", "ja": "出力", "vi": "Output"},
"dashboard.card_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"},
"dashboard.card_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
"dashboard.card_turns": {"en": "{n} turns", "ja": "{n} ターン", "vi": "{n} lượt"},
"dashboard.prices_label": {
"en": "Unit price (USD / 1M tokens):", "ja": "単価 (USD / 100万トークン):",
"vi": "Đơn giá (USD / 1 triệu token):"},
"dashboard.price_in": {"en": "In", "ja": "入力", "vi": "In"},
"dashboard.price_out": {"en": "Out", "ja": "出力", "vi": "Out"},
"dashboard.price_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"},
"dashboard.habits_title": {
"en": "Usage habits overview", "ja": "利用傾向の概要", "vi": "Tổng quan thói quen sử dụng"},
"dashboard.chart_title": {"en": "Tokens / cost over time", "ja": "トークン/コスト推移",
"vi": "Token / chi phí theo thời gian"},
"dashboard.strategy_btn": {"en": "Apply saving strategy", "ja": "節約戦略を適用",
"vi": "Áp dụng chiến lược tiết kiệm"},
"dashboard.strategy_tooltip": {
"en": "Apply the AI's cost-saving strategy: auto-compress earlier + digest context before each turn.",
"ja": "AIの節約戦略を適用:早めに自動圧縮+各ターン前にコンテキストを要約。",
"vi": "Áp dụng chiến lược tiết kiệm của AI: tự động nén sớm hơn + tóm gọn ngữ cảnh trước mỗi lượt."},
"dashboard.strategy_title": {"en": "Apply saving strategy", "ja": "節約戦略の適用",
"vi": "Áp dụng chiến lược tiết kiệm"},
"dashboard.strategy_confirm": {
"en": "Turn on auto-compress (earlier, at 60%) and compress context before each turn to cut tokens?",
"ja": "自動圧縮(60%で早めに)とターン前のコンテキスト圧縮を有効にしてトークンを削減しますか?",
"vi": "Bật tự động nén (sớm hơn, ở 60%) và nén ngữ cảnh trước mỗi lượt để giảm token?"},
"dashboard.strategy_applied": {
"en": "Saving strategy applied: auto-compress on, compress-before-send on.",
"ja": "節約戦略を適用:自動圧縮ON、送信前圧縮ON。",
"vi": "Đã áp dụng: bật tự động nén và nén trước khi gửi."},
"dashboard.gran_day": {"en": "By day", "ja": "日別", "vi": "Theo ngày"},
"dashboard.gran_week": {"en": "By week", "ja": "週別", "vi": "Theo tuần"},
"dashboard.gran_month": {"en": "By month", "ja": "月別", "vi": "Theo tháng"},
"dashboard.gran_year": {"en": "By year", "ja": "年別", "vi": "Theo năm"},
"dashboard.ref_last_week": {"en": "Last week", "ja": "先週", "vi": "Tuần trước"},
"dashboard.ref_last_month": {"en": "Last month", "ja": "先月", "vi": "Tháng trước"},
"dashboard.ref_last_year": {"en": "Last year", "ja": "昨年", "vi": "Năm trước"},
"usage.budget_title": {"en": "Budget", "ja": "予算", "vi": "Ngân sách"},
"usage.budget_no_budget": {"en": "No budget set", "ja": "予算未設定", "vi": "Chưa đặt Budget"},
"usage.budget_used_pct": {"en": "{pct}% used", "ja": "{pct}% 使用済み", "vi": "Đã dùng {pct}%"},
"usage.budget_over_warning": {"en": "⚠ Over 85% of budget used",
"ja": "⚠ 予算の85%以上を使用",
"vi": "⚠ Đã dùng quá 85% Budget"},
"usage.budget_apply_tooltip": {"en": "Set this as the budget (starts a fresh remaining-balance window)",
"ja": "この金額を予算として設定(残高の計算を今から開始)",
"vi": "Đặt số này làm Budget (tính số dư mới từ bây giờ)"},
"usage.budget_spin_tooltip": {"en": "Enter the budget amount directly, then click ✓",
"ja": "予算額を直接入力して ✓ をクリック",
"vi": "Nhập Budget trực tiếp rồi bấm ✓"},
"dashboard.chart_prev": {"en": "Previous period", "ja": "前の期間", "vi": "Kỳ trước"},
"dashboard.chart_next": {"en": "Next period", "ja": "次の期間", "vi": "Kỳ sau"},
"dashboard.metric_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
"dashboard.metric_tokens": {"en": "Tokens", "ja": "トークン", "vi": "Token"},
"dashboard.h_top": {"en": "Top token consumers (task/session)", "ja": "トークン消費上位(タスク/セッション)",
"vi": "Tiêu tốn token nhiều nhất (task/phiên)"},
"dashboard.h_by_source": {"en": "By area", "ja": "領域別", "vi": "Theo khu vực"},
"dashboard.h_avg": {"en": "Average per prompt", "ja": "1プロンプト平均", "vi": "Trung bình mỗi prompt"},
"dashboard.h_busiest_day": {"en": "Busiest day", "ja": "最も使った日", "vi": "Ngày dùng nhiều nhất"},
"dashboard.h_busiest_hour": {"en": "Busiest hour", "ja": "最も使う時間帯", "vi": "Khung giờ hay dùng"},
"dashboard.no_data": {
"en": "No usage recorded in this period yet — run a chat or a task first.",
"ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。",
"vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."},
}
+343
View File
@@ -0,0 +1,343 @@
"""Chuỗi hiển thị — phần libreoffice_view.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"structure.export_title": {"en": "Export graph PNG", "ja": "グラフを PNG でエクスポート", "vi": "Xuất đồ thị ra PNG"},
"structure.export_done": {"en": "Graph exported to {path}", "ja": "グラフを {path} にエクスポートしました", "vi": "Đã xuất đồ thị ra {path}"},
"structure.export_failed": {"en": "Export failed: {err}", "ja": "エクスポート失敗: {err}", "vi": "Xuất thất bại: {err}"},
"structure.scan_first": {"en": "Scan a graph first.", "ja": "先にグラフをスキャンしてください。", "vi": "Hãy Scan đồ thị trước."},
"structure.related_sources": {
"en": "Related files (click to open):",
"ja": "関連ファイル(クリックで開く):",
"vi": "Tệp liên quan (bấm để mở):"},
"structure.legend.dir": {"en": "Folder", "ja": "フォルダ", "vi": "Thư mục"},
"structure.legend.file": {"en": "File", "ja": "ファイル", "vi": "Tệp"},
"structure.legend.class": {"en": "Class", "ja": "クラス", "vi": "Class"},
"structure.legend.function": {"en": "Function", "ja": "関数", "vi": "Function"},
"structure.legend.method": {"en": "Method", "ja": "メソッド", "vi": "Method"},
"structure.legend.module": {"en": "Module", "ja": "モジュール", "vi": "Module"},
"structure.legend.section": {"en": "Section", "ja": "セクション", "vi": "Mục"},
"structure.legend.json_key": {"en": "JSON key", "ja": "JSONキー", "vi": "Khóa JSON"},
"structure.legend.entities": {"en": "Entities", "ja": "エンティティ", "vi": "Thực thể"},
"structure.legend.relationships": {"en": "Relationships", "ja": "関係", "vi": "Quan hệ"},
"structure.show_label": {"en": "Show label", "ja": "ラベル表示", "vi": "Hiện nhãn"},
"structure.show_relationship": {
"en": "Show relationship", "ja": "関係を表示", "vi": "Hiện quan hệ"},
"structure.edge.contains": {"en": "contains", "ja": "含む", "vi": "chứa"},
"structure.edge.defines": {"en": "defines", "ja": "定義", "vi": "định nghĩa"},
"structure.edge.method": {"en": "method", "ja": "メソッド", "vi": "phương thức"},
"structure.edge.imports": {"en": "imports", "ja": "インポート", "vi": "import"},
"structure.edge.subsection": {"en": "subsection", "ja": "サブセクション", "vi": "mục con"},
# ---- libreoffice_view.py -------------------------------------------
"libreoffice.open_btn": {"en": "Open in LibreOffice", "ja": "LibreOffice で開く", "vi": "Mở bằng LibreOffice"},
"libreoffice.not_found": {
"en": ("LibreOffice was not found. Install LibreOffice (or set the "
"SOFFICE_PATH environment variable) to view and edit documents here."),
"ja": "LibreOffice が見つかりません。ここで文書を表示/編集するには LibreOffice をインストールするか、環境変数 SOFFICE_PATH を設定してください。",
"vi": "Không tìm thấy LibreOffice. Hãy cài LibreOffice (hoặc đặt biến môi trường SOFFICE_PATH) để xem/sửa tài liệu tại đây."},
"libreoffice.windows_only": {
"en": "Embedding the editor is available on Windows. Click below to open this document in LibreOffice.",
"ja": "エディタの埋め込みは Windows でのみ利用可能です。下のボタンで LibreOffice で開いてください。",
"vi": "Nhúng trình soạn thảo chỉ khả dụng trên Windows. Bấm bên dưới để mở tài liệu bằng LibreOffice."},
"libreoffice.start_failed": {"en": "Could not start LibreOffice ({err}).", "ja": "LibreOffice を起動できませんでした({err})。", "vi": "Không khởi động được LibreOffice ({err})."},
"libreoffice.opening": {"en": "Opening the document in LibreOffice…", "ja": "LibreOffice で文書を開いています…", "vi": "Đang mở tài liệu bằng LibreOffice…"},
"libreoffice.embed_failed": {
"en": "Couldn't embed the LibreOffice window. You can open it in a separate window instead.",
"ja": "LibreOffice ウィンドウを埋め込めませんでした。別ウィンドウで開くことができます。",
"vi": "Không nhúng được cửa sổ LibreOffice. Bạn có thể mở nó ở cửa sổ riêng."},
"libreoffice.embed_error": {"en": "Couldn't embed LibreOffice ({err}).", "ja": "LibreOffice を埋め込めませんでした({err})。", "vi": "Không nhúng được LibreOffice ({err})."},
# ---- monitoring_tab.py (📊 Monitoring Dashboard) --------------------
"monitoring.title": {"en": "Monitoring Dashboard", "ja": "モニタリングダッシュボード", "vi": "Bảng giám sát"},
"monitoring.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
"monitoring.tab_security": {"en": "Security", "ja": "セキュリティ", "vi": "Bảo mật"},
"monitoring.tab_mcp": {"en": "MCP", "ja": "MCP", "vi": "MCP"},
"monitoring.tab_actions": {"en": "Actions", "ja": "アクション", "vi": "Hành động"},
"monitoring.tab_agents": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"monitoring.tab_accounts": {"en": "Accounts", "ja": "アカウント", "vi": "Tài khoản"},
"monitoring.col_time": {"en": "Time", "ja": "時刻", "vi": "Thời gian"},
"monitoring.col_role": {"en": "Agent Role", "ja": "エージェント役割", "vi": "Vai trò Agent"},
"monitoring.col_name": {"en": "Action", "ja": "アクション", "vi": "Hành động"},
"monitoring.col_result": {"en": "Result", "ja": "結果", "vi": "Kết quả"},
# Security Events shows WHICH rule fired instead of a result that is always
# the same — every security_block is recorded with ok=False.
"monitoring.col_action": {"en": "Action", "ja": "アクション", "vi": "Hành động"},
# The fourth KPI tile on Overview, as the wireframe labels it.
"monitoring.overview_calls": {"en": "Calls", "ja": "呼び出し", "vi": "Lượt gọi"},
# The fold under the Sandbox summary line — the wireframe shows only the
# summary, so the ID / created / uptime / limits rows live behind this.
"monitoring.overview_disk_free": {
"en": "{size} free", "ja": "空き {size}", "vi": "{size} trống"},
"monitoring.overview_disk_label": {"en": "Disk", "ja": "ディスク", "vi": "Đĩa"},
"monitoring.overview_sbx_detail": {
"en": "Details", "ja": "詳細", "vi": "Chi tiết"},
"monitoring.col_detail": {"en": "Detail", "ja": "詳細", "vi": "Chi tiết"},
"monitoring.col_account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
"monitoring.col_machine": {"en": "Machine", "ja": "マシン", "vi": "Máy"},
"monitoring.col_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"monitoring.col_source": {"en": "Source", "ja": "ソース", "vi": "Nguồn"},
"monitoring.active_n": {"en": "{n} running", "ja": "{n} 件実行中", "vi": "{n} đang chạy"},
"monitoring.idle": {"en": "Idle", "ja": "アイドル", "vi": "Rảnh"},
"monitoring.agent_status_title": {
"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"},
"monitoring.source_cowork": {
"en": "Cowork tab's active turns", "ja": "Cowork タブの実行中ターン",
"vi": "Lượt đang chạy của tab Cowork"},
"monitoring.source_task": {
"en": "Schedule Task's running tasks", "ja": "Schedule Task の実行中タスク",
"vi": "Task đang chạy trong Schedule Task"},
"monitoring.source_knowledge": {
"en": "GraphRAG's Ask box", "ja": "GraphRAG の Ask ボックス", "vi": "Ô hỏi của GraphRAG"},
"monitoring.source_code": {
"en": "Runs inside a Task Agent run when the task type is Code",
"ja": "タスクタイプが Code の場合、Task Agent の実行内で動作します",
"vi": "Chạy bên trong một lượt Task Agent khi loại task là Code"},
"monitoring.source_planner": {
"en": "A phase inside a running Cowork/Task turn (update_plan) — not tracked separately",
"ja": "実行中の Cowork/Task ターン内の一段階(update_plan)— 個別には追跡されません",
"vi": "Một giai đoạn trong lượt Cowork/Task đang chạy (update_plan) — không theo dõi riêng"},
"monitoring.source_reasoning": {
"en": "The model's streamed reasoning within a running turn — not tracked separately",
"ja": "実行中のターン内でモデルがストリーミングする推論 — 個別には追跡されません",
"vi": "Luồng suy luận (reasoning) của model trong lượt đang chạy — không theo dõi riêng"},
"monitoring.source_security": {
"en": "Agent Security's prompt/attachment/command validation — runs inline on the active turn",
"ja": "エージェントセキュリティのプロンプト/添付/コマンド検証 — 実行中のターン内でインライン実行",
"vi": "Kiểm duyệt prompt/đính kèm/lệnh của Agent Security — chạy inline trong lượt đang chạy"},
"monitoring.on": {"en": "On", "ja": "オン", "vi": "Bật"},
"monitoring.off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
# ---- monitoring_tab.py — Overview card dashboard ---------------------
"monitoring.tab_overview": {"en": "Overview", "ja": "概要", "vi": "Tổng quan"},
"monitoring.overview_usage_title": {
"en": "Token & Cost", "ja": "トークンとコスト", "vi": "Token & Chi phí"},
"monitoring.overview_currency": {"en": "Currency:", "ja": "通貨:", "vi": "Tiền tệ:"},
"monitoring.tab_agents_admin": {
"en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"},
"monitoring.tab_tools": {"en": "Tools", "ja": "ツール", "vi": "Công cụ"},
# ---- tools_admin_tab.py — govern built-in tools + Connectors/MCP -------
"tools_admin.hint": {
"en": "Enable or disable the built-in agent tools below. A tool toggled off is removed "
"from the agent's toolset. MCP / REST-API connectors are set up in the Connector "
"sub-tab.",
"ja": "下の組み込みエージェントツールをオン/オフします。オフにしたツールはツールセットから除外"
"されます。MCP / REST-APIコネクターは「Connector」サブタブで設定します。",
"vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. "
"Connector MCP / REST-API được thiết lập ở tab con Connector."},
"tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
"tools_admin.url_fetch_group": {
"en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)",
"vi": "Truy cập web (fetch_url)"},
"tools_admin.internet_disabled": {
"en": "Web access is OFF — enable the fetch_url tool above to allow internet access.",
"ja": "Web アクセスはオフです — 上の fetch_url ツールを有効にするとインターネットに接続できます。",
"vi": "Truy cập web đang TẮT — bật tool fetch_url ở trên để cho phép truy cập internet."},
"tools_admin.subtab_tool": {"en": "Tool", "ja": "ツール", "vi": "Tool"},
"tools_admin.subtab_connector": {"en": "Connector", "ja": "コネクター", "vi": "Connector"},
"monitoring.tab_icons": {"en": "Icons", "ja": "アイコン", "vi": "Icon"},
"icons_admin.title": {"en": "Icons", "ja": "アイコン", "vi": "Icon"},
"icons_admin.hint": {
"en": "Icons you can use for agents and flows. Type a name into a step/agent's Icon field to "
"use it. Add your own SVG icons below — they become usable by name immediately.",
"ja": "エージェントやフローに使えるアイコン。ステップ/エージェントのアイコン欄に名前を入力すると使えます。"
"下から独自のSVGアイコンを追加でき、名前ですぐ使えます。",
"vi": "Các icon dùng cho agent và flow. Gõ tên vào ô Icon của step/agent để dùng. Thêm icon SVG "
"của bạn ở dưới — dùng được ngay bằng tên."},
"icons_admin.search": {"en": "Search icons by name…", "ja": "名前でアイコンを検索…", "vi": "Tìm icon theo tên…"},
"icons_admin.builtin": {"en": "Built-in icons", "ja": "組込みアイコン", "vi": "Icon tích hợp"},
"icons_admin.custom": {"en": "Custom icons", "ja": "カスタムアイコン", "vi": "Icon tùy chỉnh"},
"icons_admin.add": {"en": "Add SVG file", "ja": "SVGファイルを追加", "vi": "Thêm tệp SVG"},
"icons_admin.paste": {"en": "Paste SVG", "ja": "SVGを貼付", "vi": "Dán SVG"},
"icons_admin.paste_prompt": {"en": "Paste the SVG markup:", "ja": "SVGマークアップを貼り付け:",
"vi": "Dán mã SVG:"},
"icons_admin.delete": {"en": "Delete custom", "ja": "カスタムを削除", "vi": "Xóa tùy chỉnh"},
"icons_admin.name_prompt": {"en": "Icon name (used in the Icon field)", "ja": "アイコン名(アイコン欄で使用)",
"vi": "Tên icon (dùng ở ô Icon)"},
"icons_admin.select_custom": {"en": "Select a custom icon to delete.",
"ja": "削除するカスタムアイコンを選択してください。",
"vi": "Hãy chọn một icon tùy chỉnh để xóa."},
"tools_admin.jira_note": {
"en": "Jira connection setup moved to the Connector tab → set it up there; here you only turn "
"the jira_search / jira_get_issue tools on or off.",
"ja": "Jira接続の設定はConnectorタブに移動しました。設定はそちらで。ここでは jira_search / "
"jira_get_issue ツールの有効/無効のみ切り替えます。",
"vi": "Phần thiết lập kết nối Jira đã chuyển sang tab Connector → cài đặt ở đó; ở đây chỉ bật/tắt "
"tool jira_search / jira_get_issue."},
"connectors.jira_group": {"en": "Jira (read)", "ja": "Jira(読み取り)", "vi": "Jira (đọc)"},
"connectors.jira_hint": {
"en": "Connect once, then just paste a Jira link into Cowork or a Co4E step — the agent reads it "
"automatically (no issue key needed). Read-only. A public Jira link works with no setup; "
"a private one needs this connection. Create a token: id.atlassian.com → Security → API tokens.",
"ja": "一度接続すれば、Cowork や Co4E ステップに Jira リンクを貼るだけで自動で読み取ります(課題キー不要)。"
"読み取り専用。公開リンクは設定不要、非公開はこの接続が必要。トークン作成: id.atlassian.com → セキュリティ → APIトークン。",
"vi": "Kết nối một lần, rồi chỉ cần dán link Jira vào Cowork hoặc bước Co4E — agent tự đọc (không cần "
"issue key). Chỉ đọc. Link Jira công khai không cần cài đặt; link riêng tư cần kết nối này. "
"Tạo token: id.atlassian.com → Security → API tokens."},
"connectors.jira_paste": {"en": "Paste a link", "ja": "リンクを貼付", "vi": "Dán link"},
"connectors.jira_paste_placeholder": {
"en": "Paste any Jira link — fills the base URL for you",
"ja": "Jiraのリンクを貼ると、ベースURLが自動入力されます",
"vi": "Dán bất kỳ link Jira nào — tự điền Base URL"},
"connectors.jira_url": {"en": "Base URL", "ja": "ベースURL", "vi": "Base URL"},
"connectors.jira_email": {"en": "Email", "ja": "メール", "vi": "Email"},
"connectors.jira_token": {"en": "API token", "ja": "APIトークン", "vi": "API token"},
"connectors.jira_save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"connectors.jira_test": {"en": "Test connection", "ja": "接続テスト", "vi": "Kiểm tra kết nối"},
"connectors.jira_saved": {"en": "Saved.", "ja": "保存しました。", "vi": "Đã lưu."},
"connectors.jira_connected": {"en": "connected", "ja": "接続済み", "vi": "đã kết nối"},
"connectors.jira_not_set": {"en": "not configured", "ja": "未設定", "vi": "chưa cấu hình"},
"connectors.jira_setup_hint": {
"en": "Double-click to connect Jira (paste any Jira link — no per-request setup after that).",
"ja": "ダブルクリックで Jira に接続(Jira リンクを貼るだけ、以降は設定不要)。",
"vi": "Nhấp đúp để kết nối Jira (dán bất kỳ link Jira nào — sau đó không cần thiết lập gì thêm)."},
"connectors.builtin_auto": {
"en": "Built-in, connects automatically", "ja": "組み込み、自動接続",
"vi": "Tích hợp, tự kết nối"},
"connectors.connect_external": {
"en": "Connect to external connectors",
"ja": "外部コネクタに接続する",
"vi": "Kết nối tới connector bên ngoài"},
"connectors.connect_external_tooltip": {
"en": ("Master switch (default ON): when off, the agent connects to NO external "
"connector or MCP server — the per-connector settings below are ignored."),
"ja": "マスタースイッチ(既定オン): オフにすると、エージェントは外部コネクタ/MCPサーバーに"
"一切接続しません(下の個別設定は無視されます)。",
"vi": ("Công tắc tổng (mặc định BẬT): khi tắt, agent sẽ KHÔNG kết nối tới bất kỳ connector "
"hay MCP server bên ngoài nào — các thiết lập từng connector bên dưới bị bỏ qua.")},
"connectors.jira_testing": {"en": "Testing…", "ja": "テスト中…", "vi": "Đang kiểm tra…"},
"connectors.jira_ok": {"en": "✓ Connected to Jira.", "ja": "✓ Jiraに接続できました。", "vi": "✓ Kết nối Jira thành công."},
"connectors.jira_fail": {"en": "✗ {err}", "ja": "✗ {err}", "vi": "✗ {err}"},
"connectors.jira_need_fields": {
"en": "Enter base URL, email and API token first.",
"ja": "先にベースURL・メール・APIトークンを入力してください。",
"vi": "Hãy nhập Base URL, Email và API token trước."},
"tools_admin.jira_group": {"en": "Jira connection", "ja": "Jira 接続", "vi": "Kết nối Jira"},
"tools_admin.jira_hint": {
"en": "Connect once, then just paste a Jira issue link into Cowork or a Co4E step — the agent "
"reads it automatically (no issue key needed). Read-only. Private Jira needs this one-time "
"connection; a public Jira link works with no setup. API token: id.atlassian.com → "
"Security → API tokens. Turn the jira tools on/off in the list above.",
"ja": "一度接続すれば、あとは Cowork や Co4E ステップに Jira のリンクを貼るだけで自動的に読み取ります"
"(課題キー不要)。読み取り専用。非公開Jiraはこの一度の接続が必要、公開リンクは設定不要。"
"APIトークン: id.atlassian.com → セキュリティ → APIトークン。ツールの有効/無効は上の一覧で。",
"vi": "Kết nối một lần, sau đó chỉ cần dán link Jira vào Cowork hoặc bước Co4E — agent tự đọc "
"(không cần nhập issue key). Chỉ đọc. Jira riêng tư cần kết nối một lần này; link Jira công "
"khai thì không cần cài đặt. API token: id.atlassian.com → Security → API tokens. Bật/tắt "
"tool jira ở danh sách phía trên."},
"tools_admin.jira_url": {"en": "Base URL", "ja": "ベースURL", "vi": "Base URL"},
"tools_admin.jira_email": {"en": "Email", "ja": "メール", "vi": "Email"},
"tools_admin.jira_token": {"en": "API token", "ja": "APIトークン", "vi": "API token"},
"tools_admin.jira_save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"tools_admin.jira_test": {"en": "Test connection", "ja": "接続テスト", "vi": "Kiểm tra kết nối"},
"tools_admin.jira_saved": {"en": "Saved.", "ja": "保存しました。", "vi": "Đã lưu."},
"tools_admin.jira_testing": {"en": "Testing…", "ja": "テスト中…", "vi": "Đang kiểm tra…"},
"tools_admin.jira_ok": {"en": "✓ Connected to Jira.", "ja": "✓ Jiraに接続できました。", "vi": "✓ Kết nối Jira thành công."},
"tools_admin.jira_fail": {"en": "✗ {err}", "ja": "✗ {err}", "vi": "✗ {err}"},
"tools_admin.jira_need_fields": {
"en": "Enter base URL, email and API token first.",
"ja": "先にベースURL・メール・APIトークンを入力してください。",
"vi": "Hãy nhập Base URL, Email và API token trước."},
# ---- Co4E (node-graph workflow studio) --------------------------------
"workspace.tab_co4e": {"en": "Co4E", "ja": "Co4E", "vi": "Co4E"},
"workspace.tab_co4e_tooltip": {
"en": "Co4E — Code for Everyone, Cowork for Everyone",
"ja": "Co4E — Code for Everyone, Cowork for Everyone",
"vi": "Co4E — Code for Everyone, Cowork for Everyone",
},
"co4e.untitled": {"en": "Untitled flow", "ja": "無題のフロー", "vi": "Flow chưa đặt tên"},
"co4e.tab_workflows": {"en": "Workflows", "ja": "ワークフロー", "vi": "Workflows"},
"co4e.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"},
"co4e.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
"co4e.new": {"en": "New", "ja": "新規", "vi": "Mới"},
"co4e.load": {"en": "Load", "ja": "読み込み", "vi": "Tải"},
"co4e.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"co4e.edit": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"co4e.new_agent": {"en": "New agent", "ja": "新規エージェント", "vi": "Agent mới"},
"co4e.manage_skills": {"en": "Manage skills…", "ja": "スキル管理…", "vi": "Quản lý skill…"},
"co4e.template": {"en": "template", "ja": "テンプレート", "vi": "mẫu"},
"co4e.saved": {"en": "saved", "ja": "保存済み", "vi": "đã lưu"},
"co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"},
"co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"},
"co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"},
"co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"},
"co4e.fit_tooltip": {
"en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示",
"vi": "Tự canh: thu phóng để thấy tất cả bước"},
"co4e.drag_hint": {
"en": "Drag a flow or agent onto the canvas (double-click a flow to load it).",
"ja": "フローやエージェントをキャンバスにドラッグ(フローはダブルクリックで読み込み)。",
"vi": "Kéo một flow hoặc agent vào canvas (double-click flow để tải)."},
"co4e.blank_step": {"en": "Blank step", "ja": "空のステップ", "vi": "Bước trống"},
"co4e.pick_agent": {"en": "Choose an agent", "ja": "エージェントを選択", "vi": "Chọn agent"},
"co4e.ai_draft": {"en": "Draft with AI", "ja": "AIで下書き", "vi": "Soạn bằng AI"},
"co4e.ai_draft_tooltip": {
"en": "Let AI write this agent's instructions from its name and role (no skill needed).",
"ja": "エージェントの名前と役割から指示文をAIが作成(スキル不要)。",
"vi": "Để AI viết hướng dẫn cho agent từ tên và vai trò (không cần skill)."},
"co4e.ai_draft_hint_title": {"en": "Draft with AI", "ja": "AIで下書き", "vi": "Soạn bằng AI"},
"co4e.ai_draft_hint_label": {
"en": "Describe what this agent should do (optional — leave blank to draft from just "
"the name/role). More detail here → more detailed instructions.",
"ja": "このエージェントが何をすべきか説明してください(任意 — 空欄なら名前/役割のみから"
"下書き)。詳しく書くほど、生成される指示も詳細になります。",
"vi": "Mô tả agent này nên làm gì (không bắt buộc — để trống sẽ soạn chỉ từ tên/vai trò). "
"Mô tả chi tiết hơn → hướng dẫn được tạo ra chi tiết hơn."},
"co4e.tt_add_step": {"en": "Add a blank step to the canvas", "ja": "空のステップをキャンバスに追加",
"vi": "Thêm một bước trống vào canvas"},
"co4e.tt_save": {"en": "Save this flow", "ja": "このフローを保存", "vi": "Lưu flow này"},
"co4e.tt_save_template": {"en": "Save as a reusable template", "ja": "再利用テンプレートとして保存",
"vi": "Lưu thành mẫu dùng lại"},
"co4e.tt_run": {"en": "Run the flow (or Interrupt while running)", "ja": "フローを実行(実行中は中断)",
"vi": "Chạy flow (hoặc Dừng khi đang chạy)"},
"co4e.tt_mode": {
"en": "Auto = each step plans then runs · Plan = dry-run a plan (read-only) · Manual = step-by-step (advance with Next step)",
"ja": "Auto=各ステップが計画して実行 · Plan=計画のみ(読取専用)· Manual=1ステップずつ(「次へ」で進む)",
"vi": "Auto = mỗi bước tự lập kế hoạch rồi chạy · Plan = chỉ lập kế hoạch (chỉ đọc) · Manual = từng bước (bấm Bước tiếp)"},
"co4e.tt_new_wf": {"en": "Start a new empty flow", "ja": "新しい空のフロー", "vi": "Tạo flow mới trống"},
"co4e.tt_load_wf": {"en": "Load the selected flow into the canvas",
"ja": "選択したフローをキャンバスに読み込み", "vi": "Tải flow đã chọn vào canvas"},
"co4e.tt_del_wf": {"en": "Delete the selected saved flow", "ja": "選択した保存フローを削除",
"vi": "Xóa flow đã lưu đang chọn"},
"co4e.tt_edit_wf": {"en": "Edit the selected flow", "ja": "選択したフローを編集",
"vi": "Sửa flow đang chọn"},
"co4e.tt_new_agent": {"en": "Create a custom agent persona", "ja": "カスタムエージェントを作成",
"vi": "Tạo một agent tùy chỉnh"},
"co4e.tt_edit_agent": {"en": "Edit the selected custom agent", "ja": "選択したカスタムエージェントを編集",
"vi": "Sửa agent tùy chỉnh đang chọn"},
"co4e.tt_del_agent": {"en": "Delete the selected custom agent", "ja": "選択したカスタムエージェントを削除",
"vi": "Xóa agent tùy chỉnh đang chọn"},
"co4e.tt_manage_skills": {"en": "Open the Skills manager", "ja": "スキル管理を開く",
"vi": "Mở trình quản lý Skill"},
"co4e.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"co4e.save_template": {"en": "Save as Template", "ja": "テンプレートとして保存", "vi": "Lưu làm mẫu"},
"co4e.flow_name": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"co4e.run": {"en": "Run", "ja": "実行", "vi": "Chạy"},
"co4e.interrupt": {"en": "Interrupt", "ja": "中断", "vi": "Dừng"},
"co4e.add": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"co4e.config_title": {"en": "Step config", "ja": "ステップ設定", "vi": "Cấu hình bước"},
"co4e.tt_collapse_config": {"en": "Collapse the config panel", "ja": "設定パネルを折りたたむ",
"vi": "Thu gọn bảng cấu hình"},
"co4e.tt_expand_config": {"en": "Expand the config panel", "ja": "設定パネルを展開",
"vi": "Mở rộng bảng cấu hình"},
"co4e.messages": {"en": "Messages", "ja": "メッセージ", "vi": "Tin nhắn"},
"co4e.tt_collapse_msgs": {"en": "Collapse the messages panel", "ja": "メッセージを折りたたむ",
"vi": "Thu gọn khung tin nhắn"},
"co4e.tt_expand_msgs": {"en": "Expand the messages panel", "ja": "メッセージを展開",
"vi": "Mở rộng khung tin nhắn"},
"co4e.mode.auto": {"en": "Auto", "ja": "自動", "vi": "Auto"},
"co4e.mode.plan": {"en": "Plan", "ja": "計画", "vi": "Plan"},
"co4e.mode.manual": {"en": "Manual", "ja": "手動", "vi": "Manual"},
# --- Co4E run manager / duplicate / status / zoom (parallel flows) ---
"co4e.copy_suffix": {"en": "copy", "ja": "コピー", "vi": "bản sao"},
"co4e.tt_dup_wf": {"en": "Duplicate the selected flow (run copies in parallel)",
"ja": "選択フローを複製(コピーを並列実行)", "vi": "Nhân bản flow đã chọn (chạy bản sao song song)"},
}
+344
View File
@@ -0,0 +1,344 @@
"""Chuỗi hiển thị — phần login_dialog.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
# ---- login_dialog.py: startup login / bootstrap / offline ----
"login.title": {"en": "Sign in", "ja": "サインイン", "vi": "Đăng nhập"},
"login.header": {"en": "Cowork-Local BamBOO", "ja": "Cowork-Local BamBOO", "vi": "Cowork-Local BamBOO"},
"login.account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
"login.code": {"en": "Access code", "ja": "アクセスコード", "vi": "Mã truy cập"},
"login.department": {"en": "Department (optional)", "ja": "部署(任意)", "vi": "Phòng ban (không bắt buộc)"},
"login.department_placeholder": {
"en": "e.g. FA.PDS — groups you automatically", "ja": "例: FA.PDS — 自動でグループ分けされます",
"vi": "vd: FA.PDS — sẽ tự động xếp vào nhóm tương ứng"},
"login.login_btn": {"en": "Log in", "ja": "ログイン", "vi": "Đăng nhập"},
"login.exit_btn": {"en": "Exit", "ja": "終了", "vi": "Thoát"},
"login.err_invalid": {
"en": "Invalid account or access code.", "ja": "アカウントまたはアクセスコードが無効です。",
"vi": "Tài khoản hoặc mã truy cập không đúng."},
"login.err_admin_exists": {
"en": "An Admin account already exists for this shared folder — the app has exactly one. Log in with an account issued by the Admin instead.",
"ja": "この共有フォルダには既に管理者アカウントが存在します(管理者は1人のみ)。管理者から発行されたアカウントでログインしてください。",
"vi": "Thư mục dùng chung này đã có tài khoản Admin — app chỉ có duy nhất 1 Admin. Hãy đăng nhập bằng tài khoản do Admin cấp."},
"login.err_missing_fields": {
"en": "Enter both a shared folder path and an account name.",
"ja": "共有フォルダのパスとアカウント名の両方を入力してください。",
"vi": "Nhập đường dẫn thư mục chia sẻ và tên tài khoản."},
"login.err_shared_dir": {
"en": "Could not create the shared folder: {error}",
"ja": "共有フォルダを作成できませんでした: {error}",
"vi": "Không tạo được thư mục chia sẻ: {error}"},
"login.bootstrap_hint": {
"en": "No accounts exist yet. Choose a shared folder (a network share or a "
"locally-synced OneDrive folder) and create the first Admin account.",
"ja": "アカウントがまだありません。共有フォルダ(ネットワーク共有、または同期済みの "
"OneDrive フォルダ)を選び、最初の管理者アカウントを作成してください。",
"vi": "Chưa có tài khoản nào. Chọn một thư mục chia sẻ (network share hoặc thư mục "
"OneDrive đã đồng bộ trên máy) và tạo tài khoản Admin đầu tiên."},
"login.shared_dir": {"en": "Shared folder", "ja": "共有フォルダ", "vi": "Thư mục chia sẻ"},
"login.browse": {"en": "Browse…", "ja": "参照…", "vi": "Chọn…"},
"login.create_admin": {
"en": "Create Admin account", "ja": "管理者アカウントを作成", "vi": "Tạo tài khoản Admin"},
"login.code_shown_title": {"en": "Admin account created", "ja": "管理者アカウントを作成しました",
"vi": "Đã tạo tài khoản Admin"},
"login.code_shown_body": {
"en": "Account: {username}\nAccess code: {code}\n\nSave this code now — it will "
"not be shown again. You are now logged in.",
"ja": "アカウント: {username}\nアクセスコード: {code}\n\n今すぐこのコードを保存してくださ"
"い — 二度と表示されません。ログインしました。",
"vi": "Tài khoản: {username}\nMã truy cập: {code}\n\nHãy lưu lại mã này ngay — mã sẽ "
"không hiển thị lại lần nào nữa. Bạn đã đăng nhập."},
"login.unreachable": {
"en": "Can't reach the shared folder:\n{path}", "ja": "共有フォルダに到達できません:\n{path}",
"vi": "Không truy cập được thư mục chia sẻ:\n{path}"},
"login.offline_hint": {
"en": "Last successful login on this machine: {username} ({role}).",
"ja": "このマシンでの最後の正常なログイン: {username} ({role})。",
"vi": "Lần đăng nhập thành công gần nhất trên máy này: {username} ({role})."},
"login.offline_btn": {"en": "Continue offline as {role}", "ja": "{role} としてオフラインで続行",
"vi": "Tiếp tục offline với vai trò {role}"},
"login.no_offline_cache": {
"en": "No previous successful login on this machine — contact your Admin.",
"ja": "このマシンでの過去のログイン履歴がありません — 管理者に連絡してください。",
"vi": "Chưa có lượt đăng nhập thành công nào trên máy này — liên hệ Admin."},
"login.retry_btn": {"en": "Retry", "ja": "再試行", "vi": "Thử lại"},
# ---- accounts_tab.py: Monitoring -> Accounts panel (Admin/Sub-admin) --
"accounts.edit_title": {"en": "Edit account", "ja": "アカウントを編集", "vi": "Sửa tài khoản"},
"accounts.add_title": {"en": "Add account", "ja": "アカウントを追加", "vi": "Thêm tài khoản"},
"accounts.f_username": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
"accounts.f_display_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"accounts.f_email": {"en": "Email", "ja": "メール", "vi": "Email"},
"accounts.f_email_placeholder": {
"en": "name@company.com (optional)", "ja": "name@company.com(任意)",
"vi": "name@company.com (không bắt buộc)"},
"accounts.f_role": {"en": "Role", "ja": "役割", "vi": "Vai trò"},
"accounts.f_department": {"en": "Department", "ja": "部門", "vi": "Bộ phận"},
"accounts.f_group": {"en": "Group", "ja": "グループ", "vi": "Nhóm"},
"accounts.f_group_name": {"en": "Group name", "ja": "グループ名", "vi": "Tên nhóm"},
"accounts.no_group": {"en": "— No group —", "ja": "— グループなし —", "vi": "— Không có nhóm —"},
"accounts.role.admin": {"en": "Admin", "ja": "管理者", "vi": "Admin"},
"accounts.role.subadmin": {"en": "Sub-admin", "ja": "サブ管理者", "vi": "Sub-admin"},
"accounts.role.user": {"en": "User", "ja": "ユーザー", "vi": "User"},
"accounts.no_shared_dir": {
"en": "No shared folder configured — set one in Settings to manage accounts.",
"ja": "共有フォルダが設定されていません — 設定でアカウント管理用のフォルダを指定してください。",
"vi": "Chưa cấu hình thư mục chia sẻ — thiết lập trong Settings để quản lý tài khoản."},
"accounts.shared_dir_hint": {"en": "Shared folder: {path}", "ja": "共有フォルダ: {path}",
"vi": "Thư mục chia sẻ: {path}"},
"accounts.ungrouped": {"en": "Ungrouped", "ja": "未分類", "vi": "Chưa có nhóm"},
"accounts.filter_all_groups": {"en": "All groups", "ja": "すべてのグループ", "vi": "Tất cả nhóm"},
"accounts.delete_title": {"en": "Delete account", "ja": "アカウントを削除", "vi": "Xóa tài khoản"},
"accounts.delete_confirm": {"en": "Delete account '{username}'?", "ja": "アカウント「{username}」を削"
"除しますか?", "vi": "Xóa tài khoản '{username}'?"},
"accounts.new_group_title": {"en": "New group", "ja": "新しいグループ", "vi": "Nhóm mới"},
"accounts.add_btn": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"accounts.edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"accounts.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"accounts.generate_code_btn": {"en": "Generate code", "ja": "コード発行", "vi": "Tạo mã"},
"accounts.new_group_btn": {"en": "New group", "ja": "新しいグループ", "vi": "Nhóm mới"},
"accounts.drag_move_hint": {
"en": "Drag an account onto a group to move it there.",
"ja": "アカウントをグループにドラッグすると移動できます。",
"vi": "Kéo tài khoản thả vào một nhóm để di chuyển đến đó."},
"accounts.err_admin_exists": {
"en": "An Admin account already exists — the app has exactly one.",
"ja": "管理者アカウントは既に存在します(1人のみ)。",
"vi": "Đã có tài khoản Admin — app chỉ có duy nhất 1 Admin."},
"accounts.search_placeholder": {
"en": "Search accounts (or type a question and press )…",
"ja": "アカウント検索(質問を入力しても可)…",
"vi": "Tìm tài khoản (hoặc gõ câu hỏi rồi bấm )…"},
"accounts.ai_search_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
"accounts.ai_search_tooltip": {
"en": "AI turns your question into a search keyword (e.g. \"who in CAE has no department?\").",
"ja": "質問をAIが検索キーワードに変換します。",
"vi": "AI chuyển câu hỏi của bạn thành từ khóa tìm kiếm (vd: \"ai trong CAE chưa có phòng ban?\")."},
"accounts.excel_template_btn": {
"en": "Excel template", "ja": "Excelテンプレート", "vi": "Mẫu Excel"},
"accounts.excel_import_btn": {
"en": "Import Excel", "ja": "Excel取り込み", "vi": "Nhập từ Excel"},
"accounts.excel_imported": {
"en": "Created {n} account(s).", "ja": "{n} 件のアカウントを作成しました。",
"vi": "Đã tạo {n} tài khoản."},
"accounts.excel_codes_saved": {
"en": "Access codes saved to: {path}", "ja": "アクセスコードの保存先: {path}",
"vi": "Mã truy cập đã lưu tại: {path}"},
"accounts.usage_title": {"en": "Usage & Cost by account", "ja": "アカウント別の使用量とコスト",
"vi": "Sử dụng & Chi phí theo tài khoản"},
"accounts.period.day": {"en": "Day", "ja": "日", "vi": "Ngày"},
"accounts.period.week": {"en": "Week", "ja": "週", "vi": "Tuần"},
"accounts.period.month": {"en": "Month", "ja": "月", "vi": "Tháng"},
"accounts.period.year": {"en": "Year", "ja": "年", "vi": "Năm"},
"accounts.col_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"accounts.col_account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
"accounts.col_machine": {"en": "Machine", "ja": "マシン", "vi": "Máy"},
"accounts.col_department": {"en": "Department", "ja": "部門", "vi": "Bộ phận"},
"accounts.col_tokens": {"en": "Tokens", "ja": "トークン", "vi": "Token"},
"accounts.col_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
# ---- app.py: top bar, tabs, toasts, tray ------------------------
"app.logo": {"en": "Cowork-Local BamBOO", "ja": "Cowork-Local BamBOO", "vi": "Cowork-Local BamBOO"},
"app.provider": {"en": "Provider:", "ja": "プロバイダー:", "vi": "Nhà cung cấp:"},
"app.language": {"en": "Language:", "ja": "言語:", "vi": "Ngôn ngữ:"},
"app.settings": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"},
"app.tab.dashboard": {"en": "Dashboard", "ja": "Dashboard", "vi": "Dashboard"},
"app.tab.schedule": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"app.tab.code": {"en": "Code", "ja": "Code", "vi": "Code"},
"app.tab.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
"app.tab.workspace": {"en": "Workspace", "ja": "ワークスペース", "vi": "Workspace"},
"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.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"},
"app.nav.menu_label": {"en": "MENU", "ja": "MENU", "vi": "MENU"},
# Shown on the rail rows the project gate disables (Cowork, GraphRAG) —
# they stay listed and greyed instead of disappearing from the menu.
"app.nav.needs_project": {
"en": "Select a project first", "ja": "先にプロジェクトを選択してください",
"vi": "Chọn project trước"},
# Rail header: the project a new chat will be created in, and what to do
# when there is no project yet.
"app.nav.project_pick": {
"en": "Project for new chats", "ja": "新しいチャットのプロジェクト",
"vi": "Project cho đoạn chat mới"},
"app.nav.no_project": {
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
"app.nav.all_projects": {
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
"app.nav.create_project_first": {
"en": "Create a project first", "ja": "先にプロジェクトを作成してください",
"vi": "Tạo project trước"},
# ---- workspace_tab.py (Projects — Claude-Projects style) -----------
"workspace.header": {"en": "Manage projects", "ja": "プロジェクト管理", "vi": "Quản lý project"},
"workspace.tab_cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"workspace.tab_graphrag": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
"workspace.tab_project": {"en": "Project", "ja": "プロジェクト", "vi": "Project"},
"workspace.tab_folder": {"en": "Folder", "ja": "フォルダ", "vi": "Thư mục"},
"folder.path_placeholder": {
"en": "Folder path", "ja": "フォルダのパス", "vi": "Đường dẫn thư mục"},
"folder.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
"folder.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"folder.open_external": {
"en": "Open externally", "ja": "外部で開く", "vi": "Mở bằng app ngoài"},
"folder.preview": {"en": "Preview", "ja": "プレビュー", "vi": "Xem trước"},
"folder.edit": {"en": "Edit", "ja": "編集", "vi": "Chỉnh sửa"},
"folder.select_file": {
"en": "Select a file in the tree to view or edit it.",
"ja": "ツリーでファイルを選択して表示・編集します。",
"vi": "Chọn một tệp trong cây thư mục để xem hoặc chỉnh sửa."},
"folder.binary_file": {
"en": "Binary or very large file — open it externally to view.",
"ja": "バイナリまたは非常に大きいファイルです — 外部で開いて表示してください。",
"vi": "Tệp nhị phân hoặc quá lớn — mở bằng app ngoài để xem."},
"folder.converting": {
"en": "Rendering document… (converting to PDF via LibreOffice)",
"ja": "ドキュメントを表示中…(LibreOffice で PDF に変換しています)",
"vi": "Đang hiển thị tài liệu… (chuyển sang PDF bằng LibreOffice)"},
"folder.doc_unreadable": {
"en": "Could not extract text ({note}). Open it externally for the full document.",
"ja": "テキストを抽出できませんでした ({note})。完全な文書は外部で開いてください。",
"vi": "Không trích xuất được nội dung ({note}). Mở bằng app ngoài để xem đầy đủ."},
"folder.saved": {"en": "Saved {name}", "ja": "{name} を保存しました", "vi": "Đã lưu {name}"},
"folder.ai_edit": {"en": "AI Edit", "ja": "AI 編集", "vi": "AI Edit"},
"folder.ai_edit_tooltip": {
"en": "Edit the open file with AI (uses the Cowork conversation context)",
"ja": "AI で開いているファイルを編集(Cowork の会話コンテキストを利用)",
"vi": "Dùng AI chỉnh sửa file đang mở (dùng ngữ cảnh hội thoại Cowork)"},
"folder.ai_placeholder": {
"en": "Describe the edit… (e.g. add error handling)",
"ja": "編集内容を入力…(例: エラー処理を追加)",
"vi": "Mô tả chỉnh sửa… (vd: thêm xử lý lỗi)"},
"folder.ai_send": {"en": "Send", "ja": "送信", "vi": "Gửi"},
"folder.ai_no_file": {
"en": "Open a text/code file in Edit mode first.",
"ja": "先にテキスト/コードファイルを編集モードで開いてください。",
"vi": "Hãy mở một file text/code ở chế độ Edit trước."},
"folder.ai_applied": {
"en": "✓ Applied the edit — review it and Save.",
"ja": "✓ 編集を適用しました — 確認して保存してください。",
"vi": "✓ Đã áp dụng chỉnh sửa — kiểm tra rồi Lưu."},
"folder.ai_empty": {
"en": "(the model didn't return an edited file)",
"ja": "(モデルは編集後のファイルを返しませんでした)",
"vi": "(model không trả về file đã chỉnh sửa)"},
"folder.ai_error": {
"en": "AI edit failed: {err}", "ja": "AI 編集に失敗しました: {err}",
"vi": "AI edit thất bại: {err}"},
"folder.ai_running": {
"en": "AI is editing {name}… (keeps running while you do other things)",
"ja": "AI が {name} を編集中…(他の作業をしていても継続します)",
"vi": "AI đang chỉnh sửa {name}… (vẫn chạy tiếp khi bạn làm việc khác)"},
"folder.ai_done": {
"en": "AI edit finished for {name} — review it in the Folder tab.",
"ja": "{name} の AI 編集が完了しました — Folder タブで確認してください。",
"vi": "AI edit xong cho {name} — kiểm tra ở tab Folder."},
"folder.ai_status_running": {
"en": "processing…", "ja": "処理中…", "vi": "đang xử lí…"},
"folder.ai_status_done": {
"en": "done", "ja": "完了", "vi": "xong"},
"folder.ai_planning": {
"en": "Planning…", "ja": "計画中…", "vi": "Đang lập kế hoạch…"},
"folder.ai_apply": {"en": "Apply", "ja": "適用", "vi": "Áp dụng"},
"folder.ai_discard": {"en": "Discard", "ja": "破棄", "vi": "Hủy"},
"folder.ai_proposed": {
"en": "Proposed changes (review)", "ja": "変更案(確認)",
"vi": "Thay đổi đề xuất (xem lại)"},
"folder.ai_review_hint": {
"en": "Review the diff, then Apply or Discard.",
"ja": "差分を確認してから、適用または破棄してください。",
"vi": "Xem lại diff rồi bấm Áp dụng hoặc Hủy."},
"folder.ai_proposed_status": {
"en": "AI proposed an edit for {name} — review & Apply.",
"ja": "{name} の編集案が出ました — 確認して適用してください。",
"vi": "AI đề xuất chỉnh sửa {name} — xem lại & Áp dụng."},
"folder.ai_discarded": {
"en": "Discarded — the file was not changed.",
"ja": "破棄しました — ファイルは変更されていません。",
"vi": "Đã hủy — file không bị thay đổi."},
"folder.ai_new_file": {"en": "a new file", "ja": "新規ファイル", "vi": "file mới"},
"folder.ai_proposed_new": {
"en": "Proposed NEW file: {name} (review)",
"ja": "新規ファイルの提案: {name}(確認)",
"vi": "Đề xuất tạo file MỚI: {name} (xem lại)"},
"folder.ai_created": {
"en": "Created {name}", "ja": "{name} を作成しました", "vi": "Đã tạo {name}"},
"folder.ai_image_confirm_title": {
"en": "Confirm image change", "ja": "画像変更の確認", "vi": "Xác nhận sửa ảnh"},
"folder.ai_image_confirm": {
"en": "This edit replaces one or more images in the slide. Proceed?",
"ja": "この編集はスライド内の画像を置き換えます。実行しますか?",
"vi": "Chỉnh sửa này sẽ thay ảnh trong slide. Tiếp tục?"},
"folder.ai_image_declined": {
"en": "Image change cancelled.", "ja": "画像の変更をキャンセルしました。",
"vi": "Đã hủy thay đổi ảnh."},
"folder.ai_image_confirm_gen": {
"en": "This will GENERATE image(s) with the AI model and save them into the folder. Proceed?",
"ja": "AI モデルで画像を生成してフォルダに保存します。実行しますか?",
"vi": "Sẽ TẠO ảnh bằng model AI và lưu vào thư mục. Tiếp tục?"},
"folder.ai_image_plan": {
"en": "Will generate these illustration image(s):",
"ja": "以下のイラスト画像を生成します:",
"vi": "Sẽ tạo các ảnh minh họa sau:"},
"folder.ai_generating": {
"en": "Generating image(s)…", "ja": "画像を生成中…", "vi": "Đang tạo ảnh…"},
"folder.ai_image_created": {
"en": "Generated image {name}", "ja": "画像 {name} を生成しました",
"vi": "Đã tạo ảnh {name}"},
"folder.ai_image_failed": {
"en": "Image generation failed: {err}", "ja": "画像生成に失敗しました: {err}",
"vi": "Tạo ảnh thất bại: {err}"},
"folder.ai_model_label": {"en": "Model:", "ja": "モデル:", "vi": "Model:"},
"folder.ai_model_auto": {
"en": "(auto — provider default)", "ja": "(自動 — 既定モデル)",
"vi": "(tự động — model mặc định)"},
"folder.ai_image_suggest": {
"en": "💡 Tip: pick model '{model}' above for image generation.",
"ja": "💡 画像生成には上のモデル '{model}' を選ぶのがおすすめです。",
"vi": "💡 Gợi ý: chọn model '{model}' ở trên để tạo ảnh."},
"folder.ai_image_suggest_all": {
"en": "💡 This request involves images. Image-capable models found on other providers:",
"ja": "💡 このリクエストは画像を含みます。他プロバイダーで見つかった画像対応モデル:",
"vi": "💡 Yêu cầu này liên quan đến ảnh. Model tạo ảnh tìm thấy ở các provider khác:"},
"folder.ai_image_none": {
"en": "💡 This request involves images, but no image-capable model was found on any configured provider.",
"ja": "💡 このリクエストは画像を含みますが、設定済みのどのプロバイダーにも画像対応モデルが見つかりませんでした。",
"vi": "💡 Yêu cầu này liên quan đến ảnh, nhưng không tìm thấy model tạo ảnh ở provider nào đã cấu hình."},
"folder.ai_image_use_selected": {
"en": "💡 No dedicated image model found — will use your selected model '{model}' to generate images.",
"ja": "💡 専用の画像モデルが見つかりません — 選択中のモデル '{model}' で画像を生成します。",
"vi": "💡 Không tìm thấy model tạo ảnh chuyên biệt — sẽ dùng model bạn đã chọn '{model}' để tạo ảnh."},
"folder.ai_queued": {
"en": "⏳ Queued (#{n}) — runs after the current edit.",
"ja": "⏳ キューに追加 (#{n}) — 現在の編集の後に実行します。",
"vi": "⏳ Đã thêm vào hàng đợi (#{n}) — chạy sau lệnh hiện tại."},
"folder.ai_queue_count": {
"en": "{n} queued", "ja": "{n} 件待機中", "vi": "{n} đang chờ"},
"terminal.title": {"en": "Terminal", "ja": "ターミナル", "vi": "Terminal"},
"terminal.run": {"en": "Run", "ja": "実行", "vi": "Chạy"},
"terminal.placeholder": {
"en": "Type a command and press Enter…", "ja": "コマンドを入力して Enter…",
"vi": "Nhập lệnh rồi nhấn Enter…"},
"terminal.expand_tooltip": {
"en": "Expand terminal", "ja": "ターミナルを開く", "vi": "Mở terminal"},
"terminal.collapse_tooltip": {
"en": "Collapse terminal", "ja": "ターミナルを閉じる", "vi": "Thu gọn terminal"},
"terminal.busy": {
"en": "[a command is still running]", "ja": "[コマンドがまだ実行中です]",
"vi": "[đang chạy một lệnh khác]"},
"terminal.cd_error": {
"en": "cd: no such directory: {path}", "ja": "cd: ディレクトリがありません: {path}",
"vi": "cd: không có thư mục: {path}"},
"terminal.launch_error": {
"en": "[failed to launch the shell]", "ja": "[シェルの起動に失敗しました]",
"vi": "[không khởi chạy được shell]"},
}
+40
View File
@@ -0,0 +1,40 @@
"""Chuỗi hiển thị — phần monitoring_overview.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"monitoring.overview_sandbox_id": {"en": "Sandbox ID", "ja": "サンドボックス ID", "vi": "Sandbox ID"},
"monitoring.overview_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"monitoring.overview_status_running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
"monitoring.overview_created": {"en": "Created", "ja": "作成日時", "vi": "Tạo lúc"},
"monitoring.overview_uptime": {"en": "Uptime", "ja": "稼働時間", "vi": "Thời gian hoạt động"},
"monitoring.overview_resource_limits": {
"en": "Resource Limits", "ja": "リソース制限", "vi": "Giới hạn tài nguyên"},
"monitoring.overview_edit": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"monitoring.overview_network_label": {"en": "Network", "ja": "ネットワーク", "vi": "Mạng"},
"monitoring.overview_network_disabled": {"en": "Disabled", "ja": "無効", "vi": "Đã tắt"},
"monitoring.overview_network_enabled": {"en": "Enabled", "ja": "有効", "vi": "Đang mở"},
"monitoring.overview_permissions_title": {"en": "Permissions", "ja": "権限", "vi": "Quyền"},
"monitoring.overview_perm_fs": {"en": "File System", "ja": "ファイルシステム", "vi": "Hệ thống file"},
"monitoring.overview_perm_fs_value": {"en": "Read/Write", "ja": "読み書き", "vi": "Đọc/Ghi"},
"monitoring.overview_perm_network": {"en": "Network", "ja": "ネットワーク", "vi": "Mạng"},
"monitoring.overview_perm_network_blocked": {"en": "Blocked", "ja": "ブロック", "vi": "Bị chặn"},
"monitoring.overview_perm_network_allowed": {"en": "Allowed", "ja": "許可", "vi": "Cho phép"},
"monitoring.overview_perm_process": {"en": "Process", "ja": "プロセス", "vi": "Tiến trình"},
"monitoring.overview_perm_process_value": {"en": "Limited", "ja": "制限あり", "vi": "Bị hạn chế"},
"monitoring.overview_perm_env": {"en": "Environment", "ja": "実行環境", "vi": "Môi trường"},
"monitoring.overview_perm_env_value": {"en": "Restricted", "ja": "制限あり", "vi": "Bị giới hạn"},
"monitoring.overview_audit_title": {"en": "Audit Log", "ja": "監査ログ", "vi": "Audit Log"},
"monitoring.overview_view_all": {"en": "View all", "ja": "すべて表示", "vi": "Xem tất cả"},
"monitoring.time_just_now": {"en": "just now", "ja": "たった今", "vi": "vừa xong"},
"monitoring.time_minutes_ago": {"en": "{n}m ago", "ja": "{n}分前", "vi": "{n} phút trước"},
"monitoring.time_hours_ago": {"en": "{n}h ago", "ja": "{n}時間前", "vi": "{n} giờ trước"},
"monitoring.time_days_ago": {"en": "{n}d ago", "ja": "{n}日前", "vi": "{n} ngày trước"},
"monitoring.na": {"en": "—", "ja": "—", "vi": "—"},
}
+343
View File
@@ -0,0 +1,343 @@
"""Chuỗi hiển thị — phần settings_dialog.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"settings.sandbox_block_network": {
"en": "Block network for agent-run commands",
"ja": "エージェントが実行するコマンドのネットワークをブロック",
"vi": "Chặn mạng cho lệnh do agent chạy"},
"settings.allow_url_fetch": {
"en": "Allow the agent to fetch URLs (web pages, SharePoint / OneDrive links)",
"ja": "エージェントによるURL取得を許可(Webページ、SharePoint / OneDriveリンク)",
"vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"},
"settings.allow_url_fetch_tooltip": {
"en": ("Lets the agent's fetch_url tool read web pages, online documents and "
"SharePoint/OneDrive share links to search & process them. Separate from "
"'Block network' (which only sandboxes shell commands). Default: on."),
"ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを"
"読み取れるようにします。「ネットワークをブロック」(シェルコマンド用)とは別です。既定: オン。",
"vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ "
"SharePoint/OneDrive để tìm kiếm & xử lý. Tách biệt với 'Chặn mạng' (chỉ áp cho lệnh "
"shell). Mặc định: bật.")},
"settings.test_internet": {
"en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"},
"settings.test_internet_tooltip": {
"en": ("Live-checks the app's own outbound HTTPS path (the same one fetch_url uses) "
"and reports the concrete reason if it can't reach the internet."),
"ja": "アプリ自身の送信HTTPS経路(fetch_urlと同じ)を実際にテストし、インターネットに到達できない"
"場合は具体的な理由を表示します。",
"vi": ("Kiểm tra trực tiếp đường HTTPS ra ngoài của app (đúng đường mà fetch_url dùng) và "
"báo lý do cụ thể nếu không truy cập được internet.")},
"settings.testing_internet": {
"en": "Testing internet access…", "ja": "インターネット接続をテスト中…",
"vi": "Đang kiểm tra truy cập internet…"},
"settings.sandbox_block_network_tooltip": {
"en": ("Policy-level control (proxy env vars point at a black hole) — not a kernel "
"firewall. Combine with the command whitelist above for defense in depth."),
"ja": "ポリシーレベルの制御です(プロキシ環境変数をブラックホールに向ける)— カーネルレベルの"
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải "
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."},
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
"settings.sandbox_disk_label": {"en": "Disk I/O limit", "ja": "ディスク I/O 制限", "vi": "Giới hạn disk I/O"},
"settings.sandbox_hint": {
"en": ("Applies to every run_command/install_package the agent executes "
"(Cowork, Code tab, and Schedule Task alike). 0 = unlimited. This layer is "
"independent of \"Agent Security\" above — it still applies even while that "
"toggle is off."),
"ja": "エージェントが実行するすべての run_command/install_package に適用されます"
"(Cowork、Code タブ、Schedule Task 共通)。0 = 無制限。この機能は上の「Agent "
"Security」とは独立しており、そのトグルがオフの間も適用され続けます。",
"vi": "Áp dụng cho mọi run_command/install_package mà agent chạy (Cowork, tab Code, "
"và Schedule Task). 0 = không giới hạn. Lớp này độc lập với \"Agent Security\" "
"ở trên — vẫn áp dụng ngay cả khi tắt Agent Security."},
"settings.group.mcp": {"en": "MCP Servers", "ja": "MCP サーバー", "vi": "MCP Servers"},
"settings.mcp_hint": {
"en": ("Connect to external MCP (Model Context Protocol) servers — e.g. the official "
"filesystem/GitHub/brave-search servers — and their tools become available to "
"the agent alongside Microsoft 365 and the built-in file/command tools."),
"ja": "外部の MCP(Model Context Protocol)サーバー(公式の filesystem/GitHub/brave-search "
"サーバーなど)に接続すると、そのツールが Microsoft 365 や組み込みのファイル/コマンド"
"ツールと並んでエージェントから利用できるようになります。",
"vi": "Kết nối tới các MCP server bên ngoài (vd: server filesystem/GitHub/brave-search chính "
"thức) — tool của chúng sẽ khả dụng cho agent cùng với Microsoft 365 và tool file/lệnh "
"có sẵn."},
"settings.mcp_add_btn": {"en": "Add server…", "ja": "サーバーを追加…", "vi": "Thêm server…"},
"settings.mcp_edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"settings.mcp_delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"settings.mcp_no_servers": {
"en": "(No MCP servers configured — click 'Add server…')",
"ja": "(MCP サーバーが設定されていません。「サーバーを追加…」をクリック)",
"vi": "(Chưa cấu hình MCP server nào — bấm 'Thêm server…')"},
"settings.mcp_delete_confirm": {
"en": "Remove MCP server \"{name}\"?", "ja": "MCP サーバー「{name}」を削除しますか?",
"vi": "Xóa MCP server \"{name}\"?"},
"mcp.add_title": {"en": "Add MCP server", "ja": "MCP サーバーを追加", "vi": "Thêm MCP server"},
"mcp.edit_title": {"en": "Edit MCP server", "ja": "MCP サーバーを編集", "vi": "Sửa MCP server"},
"mcp.name_label": {"en": "Name", "ja": "名前", "vi": "Tên"},
"mcp.name_placeholder": {"en": "e.g. filesystem", "ja": "例: filesystem", "vi": "vd: filesystem"},
"mcp.command_label": {"en": "Command", "ja": "コマンド", "vi": "Lệnh"},
"mcp.command_placeholder": {"en": "e.g. npx", "ja": "例: npx", "vi": "vd: npx"},
"mcp.args_label": {"en": "Arguments", "ja": "引数", "vi": "Tham số"},
"mcp.args_placeholder": {
"en": "e.g. -y @modelcontextprotocol/server-filesystem C:\\Data",
"ja": "例: -y @modelcontextprotocol/server-filesystem C:\\Data",
"vi": "vd: -y @modelcontextprotocol/server-filesystem C:\\Data"},
"mcp.hint": {
"en": ("The server is launched as a subprocess and talked to over stdio (the standard "
"MCP transport) — the SAME way Claude Desktop/other MCP clients connect to it."),
"ja": "サーバーはサブプロセスとして起動され、stdio(標準の MCP トランスポート)で通信します"
"— Claude Desktop など他の MCP クライアントと同じ方式です。",
"vi": "Server được khởi chạy như 1 subprocess và giao tiếp qua stdio (giao thức MCP chuẩn) "
"— giống cách Claude Desktop hay các MCP client khác kết nối tới nó."},
# ---- settings_dialog.py / ext_connector_dialog.py: External Connectors (CAD/CAE/Office) ----
"settings.group.ext": {
"en": "Connectors (MCP)",
"ja": "コネクタ(MCP)",
"vi": "Connectors (MCP)"},
"settings.ext_moved_hint": {
"en": "Connector (MCP / REST-API) setup moved to Monitoring → Tools → Connector.",
"ja": "コネクター(MCP / REST-API)の設定は「モニタリング → ツール → Connector」へ移動しました。",
"vi": "Thiết lập Connector (MCP / REST-API) đã chuyển sang Monitoring → Công cụ → Connector."},
"settings.ext_hint": {
"en": ("One place for every external tool source — grouped as CAD (NX/CATIA/SolidWorks/"
"AutoCAD), CAE (ANSA/ABAQUS/HyperWorks/ANSYS), MS365 (Microsoft 365/OneDrive/"
"SharePoint) and Other (any generic MCP server). MS365 auto-connects via the built-in "
"server once you sign in; for the rest, point each connector at an MCP server you "
"already have or a REST API it exposes (no vendor SDK is bundled)."),
"ja": "外部ツール接続を1か所に集約 — CAD(NX/CATIA/SolidWorks/AutoCAD)、CAE(ANSA/ABAQUS/"
"HyperWorks/ANSYS)、MS365(Microsoft 365/OneDrive/SharePoint)、Other(汎用 MCP サーバー)。"
"MS365 はサインインすると内蔵サーバーで自動接続。その他は既存の MCP サーバーまたは REST API を"
"指定してください(ベンダー SDK は同梱しません)。",
"vi": "Một nơi duy nhất cho mọi nguồn tool ngoài — nhóm theo CAD (NX/CATIA/SolidWorks/AutoCAD), "
"CAE (ANSA/ABAQUS/HyperWorks/ANSYS), MS365 (Microsoft 365/OneDrive/SharePoint) và Other "
"(MCP server bất kỳ). MS365 tự kết nối qua server tích hợp sau khi đăng nhập; còn lại bạn "
"trỏ mỗi connector tới MCP server bạn đã có hoặc REST API nó cung cấp (không kèm SDK hãng nào)."},
"settings.ext_add_btn": {"en": "Add connector…", "ja": "コネクタを追加…", "vi": "Thêm connector…"},
"settings.ext_edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"settings.ext_delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"settings.ext_delete_confirm": {
"en": "Remove connector \"{name}\"?", "ja": "コネクタ「{name}」を削除しますか?",
"vi": "Xóa connector \"{name}\"?"},
"ext.add_title": {"en": "Add connector", "ja": "コネクタを追加", "vi": "Thêm connector"},
"ext.edit_title": {"en": "Edit connector", "ja": "コネクタを編集", "vi": "Sửa connector"},
"ext.category_label": {"en": "Category", "ja": "カテゴリ", "vi": "Nhóm"},
"ext.preset_label": {"en": "App", "ja": "アプリ", "vi": "Ứng dụng"},
"ext.preset_custom": {"en": "(Custom…)", "ja": "(カスタム…)", "vi": "(Tuỳ chỉnh…)"},
"ext.name_label": {"en": "Display name", "ja": "表示名", "vi": "Tên hiển thị"},
"ext.name_placeholder": {"en": "e.g. NX (Site A)", "ja": "例: NX(サイトA)", "vi": "vd: NX (Site A)"},
"ext.mode_label": {"en": "Connection type", "ja": "接続方式", "vi": "Kiểu kết nối"},
"ext.mode_mcp": {"en": "MCP server (stdio)", "ja": "MCP サーバー(stdio)", "vi": "MCP server (stdio)"},
"ext.mode_rest": {"en": "REST API", "ja": "REST API", "vi": "REST API"},
"ext.mode_builtin": {"en": "built-in, auto-connect", "ja": "内蔵・自動接続", "vi": "tích hợp, tự kết nối"},
"settings.ms365_signin_btn": {"en": "Sign in to Microsoft 365", "ja": "Microsoft 365 にサインイン",
"vi": "Đăng nhập Microsoft 365"},
"settings.ms365_signout_btn": {"en": "Sign out", "ja": "サインアウト", "vi": "Đăng xuất"},
"settings.ms365_signed_in": {"en": "Microsoft 365: signed in as {who}",
"ja": "Microsoft 365: {who} でサインイン中",
"vi": "Microsoft 365: đã đăng nhập ({who})"},
"settings.ms365_signed_out": {
"en": "Microsoft 365: not signed in — one click, no Tenant/Client ID needed.",
"ja": "Microsoft 365: 未サインイン — ワンクリック、テナント/クライアント ID 不要。",
"vi": "Microsoft 365: chưa đăng nhập — 1 cú click, không cần Tenant/Client ID."},
"settings.ms365_signing_in": {
"en": "Microsoft 365: opening sign-in… follow the code prompt.",
"ja": "Microsoft 365: サインインを開始中… コードの案内に従ってください。",
"vi": "Microsoft 365: đang mở đăng nhập… làm theo hướng dẫn mã code."},
"settings.ms365_code_hint": {
"en": ("The sign-in page opened in your browser (<a href='{url}'>{url}</a>) and the code "
"below was copied to your clipboard — just paste it, then sign in with your Microsoft "
"account. This window closes automatically when sign-in completes."),
"ja": ("ブラウザでサインインページ(<a href='{url}'>{url}</a>)を開きました。下のコードはクリップボードに"
"コピー済みです — 貼り付けて Microsoft アカウントでサインインしてください。完了すると自動で閉じます。"),
"vi": ("Trang đăng nhập đã mở trong trình duyệt (<a href='{url}'>{url}</a>) và mã bên dưới đã được "
"copy vào clipboard — chỉ cần dán, rồi đăng nhập bằng tài khoản Microsoft. Cửa sổ này tự đóng "
"khi đăng nhập xong.")},
"settings.ms365_copy_code": {"en": "Copy code", "ja": "コードをコピー", "vi": "Copy mã"},
"settings.ms365_open_link": {"en": "Open link", "ja": "リンクを開く", "vi": "Mở link"},
"settings.ms365_local_connected": {
"en": "OneDrive / SharePoint: auto-connected via local sync — no sign-in needed.\nSynced folder: {path}",
"ja": "OneDrive / SharePoint: ローカル同期で自動接続 — サインイン不要。\n同期フォルダ: {path}",
"vi": "OneDrive / SharePoint: tự động kết nối qua thư mục sync local — không cần đăng nhập.\nThư mục đã sync: {path}"},
"settings.ms365_local_none": {
"en": "OneDrive / SharePoint: no locally-synced OneDrive folder found. Install/sign in to "
"the OneDrive desktop app and sync a folder, then reopen Settings.",
"ja": "OneDrive / SharePoint: ローカル同期の OneDrive フォルダが見つかりません。OneDrive デスクトップ"
"アプリでサインインしフォルダを同期してから、設定を開き直してください。",
"vi": "OneDrive / SharePoint: chưa tìm thấy thư mục OneDrive sync trên máy. Cài/đăng nhập OneDrive "
"desktop và sync một thư mục, rồi mở lại Settings."},
"ext.command_label": {"en": "Command", "ja": "コマンド", "vi": "Lệnh"},
"ext.command_placeholder": {"en": "e.g. python or npx", "ja": "例: python または npx", "vi": "vd: python hoặc npx"},
"ext.args_label": {"en": "Arguments", "ja": "引数", "vi": "Tham số"},
"ext.args_placeholder": {"en": "e.g. -m nx_mcp_server", "ja": "例: -m nx_mcp_server", "vi": "vd: -m nx_mcp_server"},
"ext.base_url_label": {"en": "Base URL", "ja": "ベース URL", "vi": "Base URL"},
"ext.base_url_placeholder": {
"en": "e.g. https://cad-api.internal.company.com",
"ja": "例: https://cad-api.internal.company.com",
"vi": "vd: https://cad-api.internal.company.com"},
"ext.api_key_label": {"en": "API key", "ja": "API キー", "vi": "API key"},
"ext.auth_header_label": {"en": "Auth header name", "ja": "認証ヘッダー名", "vi": "Tên header xác thực"},
"ext.auth_scheme_label": {"en": "Auth scheme", "ja": "認証スキーム", "vi": "Auth scheme"},
"ext.test_btn": {"en": "Test connection", "ja": "接続テスト", "vi": "Kiểm tra kết nối"},
"ext.err_no_command": {
"en": "Enter a command first.", "ja": "先にコマンドを入力してください。", "vi": "Hãy nhập lệnh trước."},
"ext.test_mcp_ok": {
"en": "MCP server started and responded.", "ja": "MCP サーバーが起動し応答しました。",
"vi": "MCP server đã khởi chạy và phản hồi."},
"settings.sec_enabled": {
"en": "Enable AI-assisted agent security guardrails",
"ja": "AI 支援のエージェント セキュリティ ガードレールを有効化",
"vi": "Bật các lớp bảo mật agent có AI hỗ trợ"},
"settings.sec_hint": {
"en": "Three independent layers: an AI reviews the user's request and "
"attachment content against the rules below before the agent "
"acts, and a whitelist + AI control-agent checks every "
"run_command/install_package call. A violation always blocks "
"the action and emails the admin below. Each AI check fails "
"OPEN (allows) if the model itself can't be reached — a gateway "
"hiccup must never make the agent unusable.",
"ja": "3つの独立した層があります:エージェントが行動する前に、AI がユーザーの"
"リクエストと添付ファイルの内容を下記のルールと照合してチェックし、"
"ホワイトリストと AI コントロールエージェントがすべての "
"run_command/install_package 呼び出しをチェックします。違反時は常に"
"操作をブロックし、下記の管理者にメールで通知します。各 AI チェックは"
"モデルに到達できない場合は「許可」側に倒れます(フェイルオープン)— "
"ゲートウェイの一時的な不調でエージェントが使えなくなることがあっては"
"なりません。",
"vi": "Ba lớp độc lập: AI kiểm tra yêu cầu của người dùng và nội dung file "
"đính kèm theo các rule bên dưới TRƯỚC khi agent hành động, và một "
"whitelist + AI control-agent kiểm tra mọi lệnh run_command/"
"install_package. Vi phạm sẽ luôn CHẶN hành động và gửi email cho "
"admin bên dưới. Mỗi lớp kiểm tra bằng AI sẽ MẶC ĐỊNH CHO PHÉP nếu "
"không gọi được model — một sự cố gateway tạm thời không được phép "
"làm agent ngừng hoạt động."},
"settings.sec_validate_prompt": {
"en": "Validate the user's request (prompt) before acting",
"ja": "行動する前にユーザーのリクエスト(プロンプト)を検証",
"vi": "Validate yêu cầu (prompt) của người dùng trước khi hành động"},
"settings.sec_validate_attachments": {
"en": "Scan attachment/file content for malicious payloads",
"ja": "添付/ファイルの内容に悪意あるペイロードがないかスキャン",
"vi": "Scan nội dung file đính kèm để phát hiện nội dung độc hại"},
"settings.sec_validate_commands": {
"en": "Check run_command / install_package against a whitelist",
"ja": "run_command / install_package をホワイトリストと照合",
"vi": "Kiểm tra run_command / install_package theo whitelist"},
"settings.sec_command_ai_check": {
"en": "Also let an AI control-agent judge commands not covered by the whitelist",
"ja": "ホワイトリストに含まれないコマンドは AI コントロールエージェントにも判定させる",
"vi": "Cho AI control-agent xét thêm các lệnh whitelist chưa liệt kê"},
"settings.sec_whitelist_label": {"en": "Command whitelist", "ja": "コマンド ホワイトリスト", "vi": "Whitelist lệnh"},
"settings.sec_whitelist_placeholder": {
"en": "One regex pattern per line, e.g. ^pip install\\n^pytest\\n^git ",
"ja": "1行に1つの正規表現、例: ^pip install\\n^pytest\\n^git ",
"vi": "Mỗi dòng 1 regex, vd: ^pip install\\n^pytest\\n^git "},
"settings.sec_whitelist_empty_warning": {
"en": "Empty whitelist + AI check off = every command is BLOCKED "
"(fail-closed) — add a pattern above or turn AI check back on.",
"ja": "ホワイトリストが空でAIチェックも無効の場合、すべてのコマンドが"
"ブロックされます(フェイルクローズ)。上にパターンを追加するか"
"AIチェックを再度有効にしてください。",
"vi": "Whitelist trống + tắt AI-check = MỌI lệnh sẽ bị CHẶN hết "
"(fail-closed) — hãy thêm pattern ở trên hoặc bật lại AI-check."},
"settings.sec_onedrive_label": {"en": "OneDrive rules link", "ja": "OneDrive ルールへのリンク", "vi": "Link OneDrive chứa rule"},
"settings.sec_onedrive_placeholder": {
"en": "(optional) sharing link to an admin-authored .md rules document",
"ja": "(任意)管理者が作成した .md ルール文書への共有リンク",
"vi": "(tuỳ chọn) link chia sẻ tới file .md rule do admin soạn"},
"settings.sec_admin_email_label": {"en": "Admin email", "ja": "管理者メール", "vi": "Email admin"},
"settings.sec_admin_email_placeholder": {
"en": "admin@yourcompany.com — receives violation alerts via Microsoft 365",
"ja": "admin@yourcompany.com — Microsoft 365 経由で違反アラートを受信",
"vi": "admin@yourcompany.com — nhận cảnh báo vi phạm qua Microsoft 365"},
"settings.sec_rules_path_hint": {
"en": "Local admin rules file (optional, edited directly, always applied): {path}",
"ja": "ローカルの管理者ルールファイル(任意・直接編集・常に適用): {path}",
"vi": "File rule admin cục bộ (tuỳ chọn, sửa trực tiếp, luôn được áp dụng): {path}"},
"settings.group.history": {"en": "Conversation history", "ja": "会話履歴", "vi": "Lịch sử hội thoại"},
"settings.history_local": {"en": "Local (this PC)", "ja": "ローカル(このPC)", "vi": "Local (máy này)"},
"settings.history_onedrive": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
"settings.location": {"en": "Location", "ja": "保存先", "vi": "Nơi lưu"},
"settings.folder": {"en": "Folder", "ja": "フォルダ", "vi": "Thư mục"},
"settings.folder_placeholder": {
"en": "(optional) specific folder — leave empty for default",
"ja": "(任意)特定のフォルダ ― 空欄で既定値",
"vi": "(tuỳ chọn) thư mục cụ thể — để trống dùng mặc định"},
"settings.browse": {"en": "Browse…", "ja": "参照…", "vi": "Duyệt…"},
"settings.autosave": {"en": "Auto-save history after each turn", "ja": "各ターン後に履歴を自動保存", "vi": "Tự động lưu lịch sử sau mỗi lượt"},
"settings.group.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"settings.max_parallel": {"en": "Max parallel conversations", "ja": "同時実行する会話数の上限", "vi": "Số hội thoại chạy song song tối đa"},
"settings.parallel_suffix": {"en": " conversations at once", "ja": " 件を同時実行", "vi": " hội thoại cùng lúc"},
"settings.parallel_tooltip": {
"en": ("How many conversations run in parallel. Within one conversation messages always "
"run one at a time (queued); only different conversations run in parallel."),
"ja": ("並列実行する会話数です。1つの会話内のメッセージは常に1件ずつ(キュー)実行され、"
"異なる会話同士のみ並列に実行されます。"),
"vi": ("Số cuộc trò chuyện chạy song song. Trong MỘT cuộc trò chuyện, tin nhắn luôn "
"chạy lần lượt (xếp hàng) để không bị trộn lẫn; chỉ các cuộc trò chuyện khác "
"nhau mới chạy song song.")},
"settings.group.attachments": {"en": "Attachments", "ja": "添付ファイル", "vi": "Tệp đính kèm"},
"settings.max_files": {"en": "Max files", "ja": "最大ファイル数", "vi": "Số tệp tối đa"},
"settings.max_files_suffix": {"en": " files / message", "ja": " 件 / メッセージ", "vi": " tệp / tin nhắn"},
"settings.max_files_tooltip": {
"en": "Maximum number of files attachable to one message.",
"ja": "1メッセージに添付できるファイル数の上限。",
"vi": "Số tệp tối đa đính kèm vào một tin nhắn."},
"settings.max_per_file": {"en": "Max per file", "ja": "ファイルあたりの上限", "vi": "Giới hạn mỗi tệp"},
"settings.max_per_file_suffix": {"en": " K tokens / file", "ja": " Kトークン / ファイル", "vi": " K tokens / tệp"},
"settings.max_per_file_tooltip": {
"en": ("Limits how much of each attached file's content is added to the prompt; anything "
"beyond this is truncated (fewer tokens, avoids exceeding the context limit)."),
"ja": "各添付ファイルの内容をプロンプトに含める量の上限。超過分は切り捨てられます(トークン削減、コンテキスト超過回避)。",
"vi": ("Giới hạn nội dung mỗi tệp đính kèm đưa vào prompt; phần vượt sẽ bị cắt "
"(giảm token, tránh lỗi vượt context).")},
"settings.group.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
"settings.group.sandbox_limits": {"en": "Sandbox resource limits", "ja": "サンドボックスのリソース上限",
"vi": "Giới hạn tài nguyên Sandbox"},
"settings.max_nodes": {"en": "Max nodes", "ja": "最大ノード数", "vi": "Số node tối đa"},
"settings.unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
"settings.nodes_suffix": {"en": " nodes", "ja": " ノード", "vi": " node"},
"settings.nodes_tooltip": {
"en": ("Cap the number of nodes in the Structure graph (0 = unlimited). "
"A lower cap speeds up scanning/layout for large folders."),
"ja": "構造グラフのノード数上限(0=無制限)。大きなフォルダでは低い値の方が高速です。",
"vi": "Giới hạn số node trong đồ thị Cấu trúc (0 = không giới hạn). Giá trị thấp hơn giúp quét/vẽ nhanh hơn với thư mục lớn."},
"settings.max_edges": {"en": "Max edges", "ja": "最大エッジ数", "vi": "Số cạnh tối đa"},
"settings.edges_suffix": {"en": " edges", "ja": " エッジ", "vi": " cạnh"},
"settings.edges_tooltip": {
"en": "Cap the number of edges in the Structure graph (0 = unlimited).",
"ja": "構造グラフのエッジ数上限(0=無制限)。",
"vi": "Giới hạn số cạnh trong đồ thị Cấu trúc (0 = không giới hạn)."},
"settings.tip": {
"en": "Tip: set your Internal Gateway URL + API key above, then pick a model.",
"ja": "ヒント: 上で社内ゲートウェイの URL と API キーを設定してからモデルを選んでください。",
"vi": "Mẹo: điền URL Gateway nội bộ + API key ở trên, rồi chọn model."},
"settings.loading_models": {"en": "Loading models…", "ja": "モデルを読み込み中…", "vi": "Đang tải danh sách model…"},
"settings.loaded_models": {
"en": "Loaded {n} model(s) for {provider}.", "ja": "{provider} のモデルを {n} 件読み込みました。",
"vi": "Đã tải {n} model cho {provider}."},
"settings.load_failed": {"en": "Load failed: {err}", "ja": "読み込み失敗: {err}", "vi": "Tải thất bại: {err}"},
"settings.load_models_error": {
"en": "No models loaded — {err}", "ja": "モデルを読み込めませんでした — {err}",
"vi": "Không tải được model nào — {err}"},
"settings.load_models_error_unknown": {
"en": "unknown error (check base URL / API key / network).",
"ja": "不明なエラー(URL・APIキー・ネットワークを確認)。",
"vi": "lỗi không xác định (kiểm tra base URL / API key / kết nối mạng)."},
"settings.test_connection": {"en": "Test connection", "ja": "接続テスト", "vi": "Test kết nối"},
"settings.test_connection_tooltip": {
"en": "Check connectivity to this provider right now and show the real reason if it fails.",
"ja": "このプロバイダーへの接続を今すぐ確認し、失敗した場合は本当の理由を表示します。",
"vi": "Kiểm tra kết nối tới provider này ngay và hiện lý do thật nếu thất bại."},
}
+342
View File
@@ -0,0 +1,342 @@
"""Chuỗi hiển thị — phần sidebar.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"terminal.exit": {
"en": "[process exited with code {code}]", "ja": "[プロセス終了 コード {code}]",
"vi": "[tiến trình kết thúc, mã {code}]"},
"folder.save_error": {
"en": "Save failed: {err}", "ja": "保存に失敗しました: {err}", "vi": "Lưu thất bại: {err}"},
"workspace.hint": {
"en": ("Group chats into projects. Every thread in a project follows the shared "
"Instructions, works inside the project's own sandbox folder, and auto-reads "
"files placed at that folder's root (project knowledge)."),
"ja": ("チャットをプロジェクトにまとめます。プロジェクト内の各スレッドは共有の指示に従い、"
"プロジェクト専用のサンドボックスフォルダ内で動作し、そのルートに置かれたファイル"
"(プロジェクトナレッジ)を自動的に読み込みます。"),
"vi": ("Gom các cuộc chat thành project. Mọi thread trong một project tuân theo phần "
"Instructions chung, làm việc trong thư mục sandbox riêng của project, và tự đọc "
"các file đặt ở gốc thư mục đó (project knowledge)."),
},
"workspace.new_project": {"en": "New project", "ja": "新規プロジェクト", "vi": "Project mới"},
"workspace.projects_heading": {"en": "PROJECTS", "ja": "プロジェクト", "vi": "PROJECT"},
"workspace.folder_label": {"en": "Workspace folder", "ja": "作業フォルダ", "vi": "Thư mục làm việc"},
"workspace.counts": {
"en": "{chats} chats · {tasks} tasks",
"ja": "チャット {chats} · タスク {tasks}",
"vi": "{chats} đoạn chat · {tasks} task"},
"workspace.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"workspace.delete_confirm": {
"en": "Delete project “{name}”? Its conversations and files are kept (threads move to General).",
"ja": "プロジェクト「{name}」を削除しますか?会話とファイルは保持されます(スレッドは General へ移動)。",
"vi": "Xóa project “{name}”? Hội thoại và file vẫn được giữ (thread chuyển về General).",
},
"workspace.deleted": {"en": "Deleted project {name}.", "ja": "プロジェクト {name} を削除しました。", "vi": "Đã xóa project {name}."},
"workspace.conversation_project_missing": {
"en": "This conversation's project no longer exists — it can't be opened.",
"ja": "この会話のプロジェクトは既に存在しないため開けません。",
"vi": "Project của hội thoại này không còn tồn tại — không thể mở."},
"workspace.name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"workspace.description": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
"workspace.instructions": {"en": "Instructions", "ja": "Instructions", "vi": "Instructions"},
"workspace.instructions_placeholder": {
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
"vi": "vd: \"Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; luôn theo quy tắc đặt tên …\"",
},
"workspace.browse": {"en": "Change", "ja": "変更", "vi": "Đổi"},
"workspace.browse_tooltip": {
"en": "Choose the project's workspace folder (agent sandbox + shared knowledge root)",
"ja": "プロジェクトのワークスペースフォルダを選択(エージェントのサンドボックス+共有ナレッジのルート)",
"vi": "Chọn thư mục workspace của project (sandbox của agent + gốc chứa knowledge chung)",
},
"workspace.open_folder": {"en": "Open", "ja": "開く", "vi": "Mở"},
"workspace.save": {"en": "Save project", "ja": "プロジェクトを保存", "vi": "Lưu project"},
"workspace.saved": {"en": "Saved project {name}.", "ja": "プロジェクト {name} を保存しました。", "vi": "Đã lưu project {name}."},
"workspace.threads": {"en": "Conversations in this project", "ja": "このプロジェクトの会話", "vi": "Hội thoại trong project này"},
"workspace.new_chat": {"en": "New chat in this project", "ja": "このプロジェクトで新規チャット", "vi": "Chat mới trong project này"},
"workspace.default_new_name": {"en": "New project", "ja": "新規プロジェクト", "vi": "Project mới"},
"workspace.collapse_projects_tooltip": {"en": "Collapse the project list", "ja": "プロジェクト一覧を折りたたむ", "vi": "Thu gọn danh sách project"},
"workspace.expand_projects_tooltip": {"en": "Click to expand the project list", "ja": "クリックしてプロジェクト一覧を展開", "vi": "Bấm để mở rộng danh sách project"},
"app.status.ready": {"en": "Ready.", "ja": "準備完了。", "vi": "Sẵn sàng."},
"app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."},
"app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."},
"app.credit": {"en": "Made by QuanDH14", "ja": "Made by QuanDH14", "vi": "Made by QuanDH14"},
"app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"},
"app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"},
"app.tray.running_body": {
"en": "Running in the background — tasks keep working. Right-click the tray icon to Quit.",
"ja": "バックグラウンドで実行中です。タスクは継続します。終了するにはトレイアイコンを右クリックしてください。",
"vi": "Đang chạy nền — tác vụ vẫn tiếp tục. Chuột phải vào biểu tượng khay để Thoát.",
},
"app.toast.done": {"en": "{name}: done", "ja": "{name}: 完了", "vi": "{name}: hoàn thành"},
"app.toast.error": {"en": "{name}: error", "ja": "{name}: エラー", "vi": "{name}: lỗi"},
"app.toast.task_done": {"en": "Task done: {title}", "ja": "タスク完了: {title}",
"vi": "Task hoàn thành: {title}"},
"app.toast.task_failed": {"en": "Task failed: {title}", "ja": "タスク失敗: {title}",
"vi": "Task lỗi: {title}"},
# ---- sidebar.py (History) ----------------------------------------
"sidebar.header": {"en": "History", "ja": "履歴", "vi": "Lịch sử"},
"sidebar.filter.all": {"en": "All", "ja": "すべて", "vi": "Tất cả"},
"sidebar.filter.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"sidebar.filter.code": {"en": "Code", "ja": "Code", "vi": "Code"},
"sidebar.search_placeholder": {
"en": "Search by title or content…", "ja": "タイトルまたは内容で検索…",
"vi": "Tìm theo tiêu đề hoặc nội dung…"},
"sidebar.search_tooltip": {
"en": "Search conversation history by title or message content.",
"ja": "会話履歴をタイトルまたはメッセージ内容で検索します。",
"vi": "Tìm kiếm lịch sử hội thoại theo tiêu đề hoặc nội dung tin nhắn."},
"sidebar.no_matches": {"en": "(no matches)", "ja": "(一致なし)", "vi": "(không tìm thấy)"},
"sidebar.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
"sidebar.refresh_tooltip": {
"en": "Update the list + this conversation's agent status",
"ja": "一覧とこの会話のエージェント状態を更新",
"vi": "Cập nhật danh sách + trạng thái agent của hội thoại đang xem",
},
"sidebar.empty": {"en": "(empty)", "ja": "(空)", "vi": "(trống)"},
"sidebar.running_suffix": {"en": " · running", "ja": " · 実行中", "vi": " · đang chạy"},
"sidebar.expand_tooltip": {
"en": "Click to expand the History panel", "ja": "クリックして履歴パネルを展開",
"vi": "Bấm để mở lại bảng Lịch sử"},
"sidebar.collapse_tooltip": {
"en": "Collapse the History panel", "ja": "履歴パネルを折りたたむ",
"vi": "Thu gọn bảng Lịch sử"},
"sidebar.menu.pin": {"en": "Pin", "ja": "ピン留め", "vi": "Ghim"},
"sidebar.menu.unpin": {"en": "Unpin", "ja": "ピン留め解除", "vi": "Bỏ ghim"},
"sidebar.menu.rename": {"en": "Rename…", "ja": "名前を変更…", "vi": "Đổi tên…"},
"sidebar.menu.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"sidebar.rename.title": {"en": "Rename conversation", "ja": "会話の名前を変更", "vi": "Đổi tên hội thoại"},
"sidebar.rename.label": {"en": "New name:", "ja": "新しい名前:", "vi": "Tên mới:"},
"sidebar.delete.title": {"en": "Delete conversation", "ja": "会話を削除", "vi": "Xóa hội thoại"},
"sidebar.delete.confirm": {"en": "Delete '{title}'?", "ja": "「{title}」を削除しますか?", "vi": "Xóa '{title}'?"},
"sidebar.menu.delete_selected": {"en": "Delete {n} selected", "ja": "選択した{n}件を削除", "vi": "Xóa {n} mục đã chọn"},
"sidebar.delete_multi.confirm": {
"en": "Delete {n} selected conversations? This cannot be undone.",
"ja": "選択した{n}件の会話を削除しますか?元に戻せません。",
"vi": "Xóa {n} hội thoại đã chọn? Không thể hoàn tác."},
# ---- widgets.py (Plan / Files sections, collapse strips) ---------
"widgets.plan_title": {"en": "Plan", "ja": "プラン", "vi": "Plan"},
"widgets.input_files": {"en": "Input files", "ja": "入力ファイル", "vi": "Tệp đầu vào"},
"widgets.output_files": {"en": "Output files", "ja": "出力ファイル", "vi": "Tệp đầu ra"},
# ---- chat_view.py --------------------------------------------------
"chat.running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
"chat.thinking": {"en": "Thinking", "ja": "思考中", "vi": "Đang nghĩ"},
"chat.creating": {"en": "Creating", "ja": "作成中", "vi": "Đang tạo"},
"chat.editing": {"en": "Editing", "ja": "編集中", "vi": "Đang sửa"},
"chat.installing": {"en": "Installing", "ja": "インストール中", "vi": "Đang cài đặt"},
"chat.reading": {"en": "Reading", "ja": "読み込み中", "vi": "Đang đọc"},
"chat.you": {"en": "You", "ja": "あなた", "vi": "Bạn"},
"chat.assistant": {"en": "Assistant", "ja": "アシスタント", "vi": "Assistant"},
"chat.error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
"help_agent.title": {
# The audit page names this AI Assistant, and keeps it the same in every
# language — it is a product name, not a description.
"en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"},
"help_agent.greeting": {
"en": "Hello {name}, have a great working day! How can I help you use the app?",
"ja": "こんにちは {name} さん、良い一日を!アプリの使い方について何かお手伝いできますか?",
"vi": "Xin chào {name}, chúc bạn một ngày làm việc vui vẻ! Mình có thể giúp gì cho bạn khi dùng app?"},
"help_agent.default_user": {"en": "Admin", "ja": "Admin", "vi": "Admin"},
"help_agent.placeholder": {
"en": "Ask how to use the app…", "ja": "アプリの使い方を質問…",
"vi": "Hỏi cách sử dụng app…"},
"help_agent.open_tooltip": {
"en": "AI Assistant — help using the app",
"ja": "AI Assistant — アプリの使い方をサポート",
"vi": "AI Assistant — hỗ trợ sử dụng app"},
"help_agent.collapse_tooltip": {
"en": "Minimize", "ja": "最小化", "vi": "Thu nhỏ"},
"help_agent.hide_tooltip": {
"en": "Hide to the edge", "ja": "端に隠す", "vi": "Ẩn vào cạnh phải"},
"help_agent.dot_hint": {
"en": "right-click to hide",
"ja": "右クリックで非表示",
"vi": "chuột phải để ẩn"},
# The name on the launcher pill. Deliberately the same in every language —
# it is a product name, and it only shows on hover, so length is not a
# constraint the way it was on a permanently visible badge.
"help_agent.badge": {
"en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"},
"help_agent.more_tooltip": {
"en": "More", "ja": "その他", "vi": "Thêm"},
"help_agent.show_tooltip": {
"en": "Show the AI Assistant", "ja": "AI Assistant を表示",
"vi": "Hiện AI Assistant"},
"help_agent.empty_reply": {
"en": "(no answer)", "ja": "(回答なし)", "vi": "(không có phản hồi)"},
"help_agent.error": {
"en": "Sorry, I couldn't answer right now: {error}",
"ja": "申し訳ありません、今は回答できませんでした: {error}",
"vi": "Xin lỗi, hiện chưa thể trả lời: {error}"},
"chat.model_switched": {
"en": "↻ Auto-switched to {model} — re-checking the previous step, then continuing.",
"ja": "↻ {model} に自動切り替え — 直前のステップを確認してから続行します。",
"vi": "↻ Đã tự động chuyển sang {model} — kiểm tra lại bước trước rồi tiếp tục."},
"chat.provider_default_short": {
"en": "the provider's default model", "ja": "プロバイダー既定のモデル",
"vi": "model mặc định của provider"},
"chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"chat.delete_tooltip": {
"en": "Delete this message and its input/output files",
"ja": "このメッセージと入出力ファイルを削除",
"vi": "Xóa tin nhắn này và các tệp input/output của nó"},
"chat.open_workspace": {"en": "Open workspace", "ja": "作業フォルダを開く", "vi": "Mở thư mục làm việc"},
"chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
"chat.open_output_folder": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
"chat.done_marker": {"en": "Done", "ja": "完了しました", "vi": "Đã hoàn thành"},
"chat.session_folder_marker": {
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
"vi": "Thư mục output của hội thoại này"},
"chat.open_folder_short": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
"chat.diff_before": {"en": "Before", "ja": "編集前", "vi": "Trước khi sửa"},
"chat.diff_after": {"en": "After", "ja": "編集後", "vi": "Sau khi sửa"},
"chat.diff_added": {"en": "Added", "ja": "追加", "vi": "Thêm mới"},
"chat.diff_removed": {"en": "Removed", "ja": "削除", "vi": "Đã xóa"},
"chat.attachment_warning_title": {
"en": "Attachment", "ja": "添付ファイル", "vi": "Tệp đính kèm"},
"chat.attachment_failed": {
"en": "Could not read \"{name}\": {note}",
"ja": "「{name}」を読み込めませんでした: {note}",
"vi": "Không đọc được nội dung \"{name}\": {note}"},
"chat.reading_progress": {
"en": "Reading {name} — page {page}/{total}…",
"ja": "{name} を読み込み中 — {page}/{total} ページ…",
"vi": "Đang đọc {name} — trang {page}/{total}…"},
"chat.workspace_files_capped": {
"en": "Folder has more files than the per-message limit — loaded {shown}/{total} (raise it in Settings → Attachments)",
"ja": "フォルダ内のファイル数が1メッセージあたりの上限を超えています — {shown}/{total} 件を読み込みました( 設定 → 添付ファイルで変更可)",
"vi": "Thư mục có nhiều file hơn giới hạn mỗi tin nhắn — đã đọc {shown}/{total} file (đổi trong Settings → Attachments)"},
# ---- chat_panel.py (shared by Cowork & Code) ----------------------
"chatpanel.agent_label": {"en": "Agent:", "ja": "エージェント:", "vi": "Agent:"},
# ---- Auto Model Assessment & Routing (core/routing/) -----------------
"routing.toggle_label": {"en": "Routing:", "ja": "ルーティング:", "vi": "Định tuyến:"},
"routing.autorun_label": {"en": "Auto-run", "ja": "自動実行", "vi": "Tự chạy"},
"routing.autorun_tooltip": {
"en": "Auto-approve commands in THIS workspace (no confirm dialog).\nUnchecked: ask before each command. Each workspace keeps its own setting.",
"ja": "このワークスペースでコマンドを自動承認(確認なし)。\nオフ: 実行前に確認。ワークスペースごとに設定を保持します。",
"vi": "Tự động duyệt lệnh trong workspace NÀY (không hỏi xác nhận).\nBỏ chọn: hỏi trước mỗi lệnh. Mỗi workspace giữ thiết lập riêng.",
},
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
# Fallback (R03-T03): resilience mode -- never switches for a better
# score, only to rescue a selected model that cannot serve the turn.
"routing.mode_fallback": {"en": "Fallback", "ja": "フォールバック", "vi": "Dự phòng"},
"routing.toggle_tooltip": {
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.\nFallback: keep the selected model, switch only if it is unavailable.",
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。\nフォールバック: 選択モデルを維持し、利用できない場合のみ切替。",
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.\nDự phòng: giữ model đã chọn, chỉ chuyển khi model đó không dùng được.",
},
"routing.confirm_title": {
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
},
"routing.confirm_body": {
"en": "A better-fit model was found for this {task} task:\n\n{from_model} → {to_model}\n(fit gain +{gain})\n\n{reason}\n\nSwitch to it for this message?",
"ja": "この {task} タスクにより適したモデルが見つかりました:\n\n{from_model} → {to_model}\n(適合度 +{gain})\n\n{reason}\n\nこのメッセージで切り替えますか?",
"vi": "Đã tìm thấy model phù hợp hơn cho tác vụ {task} này:\n\n{from_model} → {to_model}\n(điểm phù hợp +{gain})\n\n{reason}\n\nChuyển sang model đó cho tin nhắn này?",
},
"routing.confirm_yes": {"en": "Switch", "ja": "切り替える", "vi": "Chuyển"},
"routing.confirm_no": {"en": "Keep current", "ja": "現状維持", "vi": "Giữ nguyên"},
"routing.confirm_countdown": {
"en": "Keep current ({secs}s)", "ja": "現状維持 ({secs}秒)", "vi": "Giữ nguyên ({secs}s)",
},
"routing.switched_notice": {
"en": "↪ Auto-routed to {model} ({task}, fit +{gain})",
"ja": "↪ {model} へ自動ルーティング ({task}, 適合度 +{gain})",
"vi": "↪ Đã tự chuyển sang {model} ({task}, phù hợp +{gain})",
},
"routing.reassessing": {
"en": "Assessing models…", "ja": "モデルを評価中…", "vi": "Đang đánh giá model…",
},
"routing.reassess_done": {
"en": "Model assessment complete: {count} model(s) scored.",
"ja": "モデル評価完了: {count} 件を採点しました。",
"vi": "Đánh giá model xong: đã chấm {count} model.",
},
# ---- Routing settings group (settings_dialog.py) ---------------------
"routing.settings_group": {
"en": "Auto Model Routing", "ja": "自動モデルルーティング", "vi": "Tự động định tuyến Model",
},
"routing.settings_mode": {"en": "Default mode", "ja": "既定モード", "vi": "Chế độ mặc định"},
"routing.settings_policy": {"en": "Policy", "ja": "ポリシー", "vi": "Chính sách"},
"routing.policy_quality": {"en": "Quality", "ja": "品質", "vi": "Chất lượng"},
"routing.policy_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
"routing.policy_latency": {"en": "Latency", "ja": "レイテンシ", "vi": "Độ trễ"},
"routing.policy_balanced": {"en": "Balanced", "ja": "バランス", "vi": "Cân bằng"},
"routing.settings_min_gain": {
"en": "Min score gain to switch", "ja": "切替に必要な最小スコア差", "vi": "Chênh điểm tối thiểu để chuyển",
},
"routing.settings_timeout": {
"en": "Confirm timeout (sec)", "ja": "確認タイムアウト (秒)", "vi": "Thời gian chờ xác nhận (giây)",
},
"routing.settings_interval": {
"en": "Reassess every (hours, 0=off)", "ja": "再評価間隔 (時間, 0=無効)", "vi": "Đánh giá lại mỗi (giờ, 0=tắt)",
},
"routing.settings_concurrency": {
"en": "Max probe calls per provider", "ja": "プロバイダーごとの最大プローブ数", "vi": "Số lần probe tối đa mỗi provider",
},
"routing.settings_judge": {
"en": "Judge model (blank = auto)", "ja": "ジャッジモデル (空欄=自動)", "vi": "Model chấm điểm (trống = tự động)",
},
"routing.settings_reassess_now": {
"en": "Reassess now", "ja": "今すぐ再評価", "vi": "Đánh giá lại ngay",
},
"routing.settings_hint": {
"en": "The app benchmarks each model and routes chats to the best-fit one. Probing spends tokens, so it runs on a schedule / when you add a model / when you click Reassess.",
"ja": "各モデルをベンチマークし、最適なモデルへチャットを振り分けます。プローブはトークンを消費するため、スケジュール・モデル追加時・「再評価」押下時のみ実行されます。",
"vi": "Ứng dụng benchmark từng model và định tuyến chat tới model phù hợp nhất. Probe tốn token nên chỉ chạy theo lịch / khi thêm model / khi bấm Đánh giá lại.",
},
"chatpanel.menu_open": {"en": "Open", "ja": "開く", "vi": "Mở"},
"chatpanel.menu_ai_edit": {"en": "View & AI edit", "ja": "表示 & AI編集", "vi": "Xem & sửa bằng AI"},
# ---- file_edit_dialog.py (view file + AI edit) -----------------------
"fileedit.title": {"en": "View & edit file", "ja": "ファイル表示・編集", "vi": "Xem & sửa file"},
"fileedit.browse_tooltip": {"en": "Open another file…", "ja": "別のファイルを開く…",
"vi": "Mở file khác…"},
"fileedit.reload_tooltip": {"en": "Reload from disk", "ja": "ディスクから再読み込み",
"vi": "Tải lại từ đĩa"},
"fileedit.pick_hint": {"en": "Open a file to view or edit it.",
"ja": "表示・編集するファイルを開いてください。",
"vi": "Mở một file để xem hoặc chỉnh sửa."},
"fileedit.instruction_placeholder": {
"en": "Tell the AI how to edit this file (e.g. 'fix typos', 'translate to English')…",
"ja": "このファイルの編集内容をAIに指示(例:「誤字修正」「英語に翻訳」)…",
"vi": "Nói cho AI cách sửa file này (vd: 'sửa lỗi chính tả', 'dịch sang tiếng Anh')…"},
"fileedit.ai_btn": {"en": "AI Edit", "ja": "AI編集", "vi": "Sửa bằng AI"},
"fileedit.save_btn": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"fileedit.close_btn": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"fileedit.loaded_editable": {"en": "Text file — editable.", "ja": "テキストファイル — 編集可能。",
"vi": "File văn bản — có thể sửa."},
"fileedit.loaded_readonly": {
"en": "Binary/large document — extracted text shown, read-only (view & ask only).",
"ja": "バイナリ/大きい文書 — 抽出テキストを表示(閲覧のみ、編集不可)。",
"vi": "Tài liệu nhị phân/lớn — hiển thị text trích xuất, chỉ đọc (chỉ xem & hỏi)."},
"fileedit.not_found": {"en": "File not found: {path}", "ja": "ファイルが見つかりません: {path}",
"vi": "Không tìm thấy file: {path}"},
"fileedit.needs_instruction": {"en": "Enter an edit instruction first.",
"ja": "先に編集指示を入力してください。",
"vi": "Hãy nhập yêu cầu chỉnh sửa trước."},
"fileedit.ai_working": {"en": "AI is editing…", "ja": "AIが編集中…", "vi": "AI đang chỉnh sửa…"},
"fileedit.ai_done": {"en": "AI edit applied — review, then Save.",
"ja": "AI編集を適用 — 確認して保存してください。",
"vi": "Đã áp dụng chỉnh sửa của AI — xem lại rồi Lưu."},
"fileedit.ai_empty": {"en": "The AI returned no content.", "ja": "AIが内容を返しませんでした。",
"vi": "AI không trả về nội dung."},
"fileedit.ai_failed": {"en": "AI edit failed: {err}", "ja": "AI編集に失敗: {err}",
"vi": "Sửa bằng AI thất bại: {err}"},
"fileedit.saved": {"en": "Saved {path} (original backed up as .bak).",
"ja": "{path} を保存(元は .bak にバックアップ)。",
"vi": "Đã lưu {path} (bản gốc sao lưu thành .bak)."},
}
+342
View File
@@ -0,0 +1,342 @@
"""Chuỗi hiển thị — phần skills_dialog.
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"settings.testing_connection": {"en": "Testing connection…", "ja": "接続を確認中…", "vi": "Đang kiểm tra kết nối…"},
"settings.sending_test": {"en": "Sending test…", "ja": "テスト送信中…", "vi": "Đang gửi thử…"},
"settings.test_failed": {"en": "Test failed: {err}", "ja": "テスト失敗: {err}", "vi": "Kiểm tra thất bại: {err}"},
"settings.pick_hist_dir": {"en": "Choose history folder", "ja": "履歴フォルダを選択", "vi": "Chọn thư mục lưu lịch sử"},
# ---- skills_dialog.py -----------------------------------------------
"skills.edit_title": {"en": "Edit skill", "ja": "スキルを編集", "vi": "Sửa skill"},
"skills.add_title": {"en": "Add skill", "ja": "スキルを追加", "vi": "Thêm skill"},
"skills.name_label": {"en": "Skill name", "ja": "スキル名", "vi": "Tên skill"},
"skills.name_placeholder": {
"en": "e.g. Always write unit tests", "ja": "例:常に単体テストを書く", "vi": "vd. Luôn viết unit test"},
"skills.desc_label": {"en": "Short description (optional)", "ja": "簡単な説明(任意)", "vi": "Mô tả ngắn (tuỳ chọn)"},
"skills.instructions_label": {"en": "Instructions for the agent", "ja": "エージェントへの指示", "vi": "Hướng dẫn cho agent"},
"skills.gen_from_desc": {"en": "Generate from description", "ja": "説明文から生成", "vi": "Tạo từ mô tả"},
"skills.gen_from_desc_tooltip": {
"en": "Use the AI agent to draft the instructions from the short description",
"ja": "AI エージェントで短い説明から指示文の下書きを生成します",
"vi": "Dùng AI để soạn hướng dẫn từ mô tả ngắn"},
"skills.instructions_placeholder": {
"en": "Describe the rules / guidance the agent must follow…",
"ja": "エージェントが従うべきルール/ガイドラインを記述…",
"vi": "Mô tả các quy tắc/hướng dẫn mà agent phải tuân theo…"},
"skills.generating": {"en": "Generating…", "ja": "生成中…", "vi": "Đang tạo…"},
"skills.title": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
"skills.hint": {
"en": "Tick to enable a skill. Enabled skills are followed by the agent.",
"ja": "チェックでスキルを有効化。有効なスキルはエージェントが従います。",
"vi": "Tick để bật skill. Skill đang bật sẽ được agent tuân theo."},
"skills.auto_generate": {"en": "Auto-generate", "ja": "自動生成", "vi": "Tự động tạo"},
"skills.auto_generate_tooltip": {
"en": ("Describe a skill in one line and let the AI draft the whole skill "
"(name, description and instructions) for you to review."),
"ja": "1行でスキルを説明すると、AI が名前・説明・指示文をまとめて下書きします。",
"vi": "Mô tả skill trong 1 dòng, AI sẽ tự soạn cả skill (tên, mô tả, hướng dẫn) để bạn xem lại."},
"skills.import_btn": {"en": "Import…", "ja": "インポート…", "vi": "Nhập…"},
"skills.import_tooltip": {
"en": "Import an external skill from a .skill, .json, .md or .txt file",
"ja": ".skill / .json / .md / .txt ファイルから外部スキルをインポート",
"vi": "Nhập skill từ file .skill, .json, .md hoặc .txt"},
"skills.edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"skills.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"skills.close_btn": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"skills.no_skills": {
"en": "(No skills yet — click ' Auto-generate' or 'Import…')",
"ja": "(スキルはまだありません。「 自動生成」または「インポート…」をクリック)",
"vi": "(Chưa có skill nào — bấm ' Tự động tạo' hoặc 'Nhập…')"},
"skills.auto_generate_title": {"en": "Auto-generate skill", "ja": "スキルを自動生成", "vi": "Tự động tạo skill"},
"skills.auto_generate_unavailable": {
"en": "AI generation isn't available right now.", "ja": "現在 AI 生成は利用できません。",
"vi": "Tính năng tạo bằng AI hiện chưa dùng được."},
"skills.auto_generate_prompt": {
"en": "Describe the skill you want (what should the agent do?):",
"ja": "欲しいスキルを説明してください(エージェントに何をさせたいか):",
"vi": "Mô tả skill bạn muốn (agent nên làm gì?):"},
"skills.auto_generate_failed": {
"en": "Couldn't generate a skill. Check the AI provider in Settings, or add one manually.",
"ja": "スキルを生成できませんでした。設定の AI プロバイダーを確認するか、手動で追加してください。",
"vi": "Không tạo được skill. Kiểm tra lại provider AI trong Settings, hoặc tự thêm thủ công."},
"skills.import_dialog_title": {"en": "Import skill", "ja": "スキルをインポート", "vi": "Nhập skill"},
"skills.import_dialog_filter": {
"en": "Skills (*.skill *.json *.md *.txt *.yaml *.yml *.zip);;All files (*.*)",
"ja": "スキル (*.skill *.json *.md *.txt *.yaml *.yml *.zip);;すべてのファイル (*.*)",
"vi": "Skill (*.skill *.json *.md *.txt *.yaml *.yml *.zip);;Tất cả file (*.*)"},
"skills.import_failed": {"en": "Could not import: {err}", "ja": "インポートできませんでした: {err}", "vi": "Không nhập được: {err}"},
"skills.export_btn": {"en": "Export .md", "ja": ".md エクスポート", "vi": "Xuất .md"},
"skills.export_tooltip": {
"en": "Export the selected skill to a Markdown (.md) file",
"ja": "選択したスキルを Markdown (.md) ファイルに書き出します",
"vi": "Xuất skill đang chọn ra file Markdown (.md)"},
"skills.export_pick": {
"en": "Select a skill in the list first, then click Export .md.",
"ja": "先にリストでスキルを選択してから「.md エクスポート」を押してください。",
"vi": "Hãy chọn một skill trong danh sách trước, rồi bấm Xuất .md."},
"skills.export_dialog_title": {
"en": "Export skill to Markdown", "ja": "スキルを Markdown に書き出す",
"vi": "Xuất skill ra Markdown"},
"skills.export_dialog_filter": {
"en": "Markdown (*.md);;All files (*.*)", "ja": "Markdown (*.md);;すべてのファイル (*.*)",
"vi": "Markdown (*.md);;Tất cả file (*.*)"},
"skills.export_done": {
"en": "Exported to {path}", "ja": "{path} に書き出しました", "vi": "Đã xuất ra {path}"},
"skills.export_failed": {
"en": "Could not export: {err}", "ja": "書き出せませんでした: {err}", "vi": "Không xuất được: {err}"},
"skills.duplicate_btn": {"en": "Duplicate", "ja": "複製", "vi": "Nhân bản"},
"skills.duplicate_tooltip": {
"en": "Duplicate the selected skill (a copy you can rename and edit)",
"ja": "選択したスキルを複製します(名前を変更・編集できるコピー)",
"vi": "Nhân bản skill đang chọn (bản sao có thể đổi tên và chỉnh sửa)"},
"skills.copy_name": {"en": "{name} (copy)", "ja": "{name}(コピー)", "vi": "{name} (bản sao)"},
"skills.from_template": {"en": "From template file…", "ja": "テンプレートファイルから…", "vi": "Từ file template…"},
"skills.from_template_tooltip": {
"en": ("Analyze a .pptx/.xlsx template's layout, fonts, colors and formatting "
"and draft a skill so future generated files match it."),
"ja": ".pptx/.xlsx テンプレートのレイアウト・フォント・色・書式を解析し、"
"今後生成するファイルがそれに合うようスキルを下書きします。",
"vi": "Phân tích layout/font/màu/định dạng của file template .pptx/.xlsx, soạn skill để các file tạo sau khớp với nó."},
"skills.from_template_title": {
"en": "Generate skill from template", "ja": "テンプレートからスキルを生成",
"vi": "Tạo skill từ template"},
"skills.from_template_dialog_title": {
"en": "Select a template file", "ja": "テンプレートファイルを選択",
"vi": "Chọn file template"},
"skills.from_template_dialog_filter": {
"en": "PowerPoint/Excel (*.pptx *.xlsx *.xlsm);;All files (*.*)",
"ja": "PowerPoint/Excel (*.pptx *.xlsx *.xlsm);;すべてのファイル (*.*)",
"vi": "PowerPoint/Excel (*.pptx *.xlsx *.xlsm);;Tất cả file (*.*)"},
"skills.from_template_failed": {
"en": ("Couldn't analyze this template. Make sure it's a valid .pptx/.xlsx file "
"and the AI provider in Settings works, or add the skill manually."),
"ja": "このテンプレートを解析できませんでした。有効な .pptx/.xlsx ファイルか、"
"設定の AI プロバイダーが動作しているか確認するか、手動でスキルを追加してください。",
"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.title": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"},
"flow.tab_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"flow.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"},
"flow.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
"flow.template": {"en": "Template:", "ja": "テンプレート:", "vi": "Template:"},
"flow.load_builtin": {"en": "Load Req→Demo template", "ja": "Req→Demo テンプレートを読込", "vi": "Tải template Req→Demo"},
"flow.new": {"en": "New", "ja": "新規", "vi": "Mới"},
"flow.delete_template": {"en": "Delete template", "ja": "テンプレートを削除", "vi": "Xóa template"},
"flow.name_label": {"en": "Flow name", "ja": "フロー名", "vi": "Tên flow"},
"flow.description_label": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
"flow.stages": {"en": "Stages", "ja": "ステージ", "vi": "Các bước"},
"flow.remove_stage": {"en": "Remove stage", "ja": "ステージを削除", "vi": "Xóa bước"},
"flow.stage_name": {"en": "Stage name", "ja": "ステージ名", "vi": "Tên bước"},
"flow.hint": {"en": "Hint", "ja": "ヒント", "vi": "Gợi ý"},
"flow.task_prompt": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"},
"flow.skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"},
"flow.agent": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "AI provider"},
"flow.model_label": {"en": "Agent:", "ja": "Agent:", "vi": "Agent:"},
"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_tooltip": {
"en": "Use the AI agent to expand the hint into a task prompt",
"ja": "AI エージェントでヒントをタスクプロンプトに展開します",
"vi": "Dùng AI để mở rộng gợi ý thành task prompt"},
"flow.attachments": {"en": "Attachments", "ja": "添付ファイル", "vi": "Đính kèm"},
"flow.attach_files": {"en": "Attach files…", "ja": "ファイルを添付…", "vi": "Đính kèm file…"},
"flow.attach_files_count": {
"en": "{n} file(s) attached", "ja": "{n} 件添付済み", "vi": "Đã đính kèm {n} file"},
"flow.compact_after_run": {
"en": "Compact after run", "ja": "実行後に圧縮", "vi": "Compact after run (nén sau khi chạy)"},
"flow.compact_after_run_tooltip": {
"en": "Trim older history right after this stage, freeing up token space for the next one",
"ja": "このステージの直後に古い履歴を切り詰め、次のステージ用にトークン余裕を確保します",
"vi": "Rút gọn lịch sử cũ ngay sau bước này để nhường chỗ token cho bước tiếp theo"},
"flow.self_verify": {
"en": "Self-verify before handoff", "ja": "引き渡し前に自己検証", "vi": "Self-verify trước khi bàn giao"},
"flow.self_verify_tooltip": {
"en": "Ask the agent to confirm the stage is actually complete before moving on",
"ja": "次に進む前に、このステージが本当に完了しているかエージェントに確認させます",
"vi": "Yêu cầu agent tự xác nhận đã hoàn thành đầy đủ trước khi qua bước sau"},
"flow.review_retries": {
"en": "Review-completeness retries", "ja": "完全性レビューの再試行回数", "vi": "Số lần review lại nếu chưa xong"},
"flow.review_retries_tooltip": {
"en": "If the self-check says the stage is incomplete, re-run it up to this many times (0 = off)",
"ja": "自己チェックで未完了と判定された場合、この回数まで再実行します(0 = 無効)",
"vi": "Nếu tự kiểm tra thấy chưa hoàn thành, chạy lại bước này tối đa số lần này (0 = tắt)"},
"flow.parallel_agents": {
"en": "Parallel sub-agents", "ja": "並列サブエージェント", "vi": "Sub-agent chạy song song"},
"flow.subagent_name_placeholder": {"en": "Name (e.g. backend)", "ja": "名前(例: backend)", "vi": "Tên (vd backend)"},
"flow.subagent_task_placeholder": {
"en": "Task for this sub-agent (optional — falls back to the stage task)",
"ja": "このサブエージェントのタスク(任意 — 未入力ならステージのタスクを使用)",
"vi": "Nhiệm vụ của sub-agent này (tùy chọn — bỏ trống thì dùng task của bước)"},
"flow.subagent_add": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"flow.subagent_remove": {"en": "Remove", "ja": "削除", "vi": "Xóa"},
"flow.subagent_add_from_agent": {
"en": "Add from Agent", "ja": "エージェントから追加", "vi": "Thêm từ Agent"},
"flow.subagent_no_agents": {
"en": "(no saved Agents — create one in the Agents tab)",
"ja": "(保存済みのエージェントがありません — Agents タブで作成してください)",
"vi": "(chưa có Agent nào — tạo ở tab Quản lý Agent)"},
"flow.subagent_hint": {
"en": ("Add 2+ sub-agents to make this a PARALLEL stage — they run concurrently, then "
"the stage's own Task field is used to consolidate their results into one."),
"ja": ("サブエージェントを2つ以上追加すると、このステージは並列ステージになります — "
"同時に実行され、その後ステージ自体のタスク欄で結果を1つに統合します。"),
"vi": ("Thêm từ 2 sub-agent trở lên để bước này chạy SONG SONG — chúng chạy đồng thời, "
"sau đó ô Task của chính bước này dùng để gộp kết quả lại thành một.")},
"flow.add_stage": {"en": "Add stage", "ja": "ステージを追加", "vi": "Thêm bước"},
"flow.update_stage": {"en": "Update stage", "ja": "ステージを更新", "vi": "Cập nhật bước"},
"flow.save_template": {"en": "Save as template", "ja": "テンプレートとして保存", "vi": "Lưu làm template"},
"flow.run": {"en": "Run flow", "ja": "フローを実行", "vi": "Chạy flow"},
"flow.close": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"flow.none": {"en": "(none)", "ja": "(なし)", "vi": "(không có)"},
"flow.default_agent": {"en": "Default", "ja": "デフォルト", "vi": "Mặc định"},
"flow.select_template": {"en": "— select template —", "ja": "— テンプレートを選択 —", "vi": "— chọn template —"},
"flow.new_flow_name": {"en": "New flow", "ja": "新しいフロー", "vi": "Flow mới"},
"flow.default_name": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
# ---- agent_manager_tab.py -------------------------------------------
"agentmgr.hint": {
"en": "Create reusable Agent presets (name + task + provider) — pick them as "
"parallel sub-agents from any Flow stage in the Code tab.",
"ja": "再利用できるエージェントのプリセット(名前・タスク・プロバイダー)を作成します — "
"Code タブの任意のフローステージから並列サブエージェントとして選択できます。",
"vi": "Tạo sẵn các Agent (tên + nhiệm vụ + provider) để tái sử dụng — chọn làm "
"sub-agent chạy song song từ bất kỳ bước Flow nào ở tab Code."},
"agentmgr.list_label": {"en": "Saved agents", "ja": "保存済みエージェント", "vi": "Agent đã lưu"},
"agentmgr.name_label": {"en": "Agent name", "ja": "エージェント名", "vi": "Tên agent"},
"agentmgr.desc_label": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
"agentmgr.prompt_label": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"},
"agentmgr.gen_prompt_btn": {"en": "Generate from description", "ja": "説明から生成",
"vi": "Tạo prompt từ mô tả"},
"agentmgr.gen_prompt_tooltip": {
"en": "Use the AI agent to expand the name/description into a task prompt",
"ja": "AI エージェントで名前・説明をタスクプロンプトに展開します",
"vi": "Dùng AI để mở rộng tên/mô tả thành task prompt"},
"agentmgr.provider_label": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "Provider AI"},
"agentmgr.new_btn": {"en": "New agent", "ja": "新規エージェント", "vi": "Agent mới"},
"agentmgr.save_btn": {"en": "Save agent", "ja": "エージェントを保存", "vi": "Lưu agent"},
"agentmgr.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agentmgr.delete_confirm": {
"en": "Delete agent '{name}'?", "ja": "エージェント「{name}」を削除しますか?",
"vi": "Xóa agent '{name}'?"},
# ---- permission_dialog.py ------------------------------------------
"permission.title": {"en": "Confirm action", "ja": "操作を確認", "vi": "Xác nhận thao tác"},
"permission.default_action": {"en": "Action", "ja": "操作", "vi": "Thao tác"},
"permission.subtitle_command": {
"en": "The agent wants to run this command in the working folder:",
"ja": "エージェントが作業フォルダで次のコマンドを実行しようとしています:",
"vi": "Agent muốn chạy lệnh này trong thư mục làm việc:"},
"permission.subtitle_diff": {
"en": "The agent wants to change a file (diff below):",
"ja": "エージェントがファイルを変更しようとしています(差分は下記):",
"vi": "Agent muốn thay đổi một tệp (xem diff bên dưới):"},
"permission.subtitle_default": {
"en": "The agent proposes an action:", "ja": "エージェントが操作を提案しています:",
"vi": "Agent đề xuất một thao tác:"},
"permission.approve": {"en": "Approve", "ja": "承認", "vi": "Duyệt"},
"permission.reject": {"en": "Reject", "ja": "拒否", "vi": "Từ chối"},
"permission.remember_whitelist": {
"en": "Remember — add to the command whitelist",
"ja": "記憶する — コマンドのホワイトリストに追加",
"vi": "Ghi nhớ — thêm vào whitelist lệnh"},
"permission.remember_whitelist_tooltip": {
"en": "Future commands starting the same way will be auto-approved without asking again.",
"ja": "同じように始まる今後のコマンドは、再確認なしで自動承認されます。",
"vi": "Các lệnh sau bắt đầu giống vậy sẽ được tự động duyệt, không hỏi lại."},
# ---- structure_graph_view.py ---------------------------------------
"structure.path_placeholder": {"en": "Source / document folder", "ja": "ソース/ドキュメントフォルダ", "vi": "Thư mục source/tài liệu"},
"structure.browse": {"en": "Browse…", "ja": "参照…", "vi": "Browse…"},
"structure.mode_all": {"en": "All files", "ja": "すべてのファイル", "vi": "All files"},
"structure.mode_code": {"en": "Code only", "ja": "コードのみ", "vi": "Code only"},
"structure.mode_doc": {"en": "Docs only", "ja": "ドキュメントのみ", "vi": "Docs only"},
"structure.project_none": {"en": "(no project — free path)", "ja": "(プロジェクトなし — 自由パス)", "vi": "(không gán project — path tự do)"},
"structure.project_tooltip": {
"en": "Lock the scan to a project's sandbox workspace — path becomes read-only and the "
"Agent Q&A below follows that project's shared Instructions (safer, grounded answers).",
"ja": "スキャン対象をプロジェクトのサンドボックスワークスペースに固定します — パスは読み取り専用になり、"
"下のエージェントQ&Aはそのプロジェクトの共有指示に従います(より安全で根拠のある回答)。",
"vi": "Khóa phạm vi quét vào đúng thư mục sandbox của 1 project — path chuyển sang chỉ đọc và "
"khung hỏi-đáp Agent bên dưới sẽ theo Instructions chung của project đó (an toàn hơn, "
"câu trả lời bám sát ngữ cảnh, giảm bịa đặt).",
},
"structure.scan": {"en": "Scan", "ja": "スキャン", "vi": "Scan"},
"structure.export_png": {"en": "Export PNG", "ja": "PNG エクスポート", "vi": "Xuất PNG"},
"structure.msgs_btn": {"en": "Messages", "ja": "メッセージ", "vi": "Tin nhắn"},
"structure.graph_btn": {"en": "Graph", "ja": "グラフ", "vi": "Đồ thị"},
"structure.msgs_tooltip": {
"en": "Show all conversation messages grouped by day (as JSON).",
"ja": "会話メッセージを日別にJSONで表示。",
"vi": "Xem mọi message hội thoại nhóm theo ngày (dạng JSON)."},
"structure.msgs_none": {"en": "No messages yet.", "ja": "メッセージがありません。",
"vi": "Chưa có message nào."},
"structure.open_browser": {"en": "Open in browser", "ja": "ブラウザで開く", "vi": "Mở trong trình duyệt"},
"structure.open_browser_tooltip": {
"en": "Open the full interactive D3 graph in your default browser (works in every build, including the standalone .exe)",
"ja": "既定のブラウザでフル機能の D3 グラフを開きます(スタンドアロン .exe を含むすべてのビルドで利用可能)",
"vi": "Mở đồ thị D3 đầy đủ tính năng trong trình duyệt mặc định (dùng được ở mọi bản build, kể cả file .exe độc lập)",
},
"structure.opened_browser": {
"en": "D3 graph opened in your browser at {url}",
"ja": "ブラウザで D3 グラフを開きました: {url}",
"vi": "Đã mở đồ thị D3 trong trình duyệt tại {url}",
},
"structure.cmem_ui_open": {"en": "Codebase Memory UI", "ja": "Codebase Memory UI", "vi": "Codebase Memory UI"},
"structure.cmem_ui_back": {"en": "Back to D3 view", "ja": "D3 表示に戻る", "vi": "Về đồ thị D3"},
"structure.cmem_ui_tooltip": {
"en": "Open codebase-memory-mcp's own graph UI (Graph/Projects/Control) for the current scan path.",
"ja": "現在のスキャンパスに対して codebase-memory-mcp 独自のグラフ UI(Graph/Projects/Control)を開きます。",
"vi": "Mở UI đồ thị riêng của codebase-memory-mcp (Graph/Projects/Control) cho đường dẫn đang quét."},
"structure.cmem_ui_starting": {
"en": "Starting codebase-memory-mcp UI…", "ja": "codebase-memory-mcp の UI を起動中…",
"vi": "Đang khởi động UI của codebase-memory-mcp…"},
"structure.cmem_ui_opened_embedded": {
"en": "codebase-memory-mcp UI loaded.", "ja": "codebase-memory-mcp の UI を読み込みました。",
"vi": "Đã tải UI của codebase-memory-mcp."},
"structure.cmem_ui_opened_browser": {
"en": "codebase-memory-mcp UI opened in your browser at {url}",
"ja": "ブラウザで codebase-memory-mcp の UI を開きました: {url}",
"vi": "Đã mở UI của codebase-memory-mcp trong trình duyệt tại {url}"},
"structure.cmem_ui_not_built": {
"en": "This codebase-memory-mcp build has no embedded UI. Install the "
"'codebase-memory-mcp-ui' release asset from the project's GitHub "
"releases to use this view. ({err})",
"ja": "この codebase-memory-mcp ビルドには UI が組み込まれていません。このビューを使うには "
"GitHub リリースから 'codebase-memory-mcp-ui' をインストールしてください。({err})",
"vi": "Bản build codebase-memory-mcp này không có UI nhúng. Cần cài "
"release asset 'codebase-memory-mcp-ui' từ trang GitHub Releases của "
"dự án để dùng chức năng này. ({err})"},
"structure.cmem_ui_failed": {
"en": "Could not open codebase-memory-mcp UI: {err}",
"ja": "codebase-memory-mcp の UI を開けませんでした: {err}",
"vi": "Không mở được UI của codebase-memory-mcp: {err}"},
"structure.collapse_agent_tooltip": {"en": "Collapse the Agent panel", "ja": "エージェントパネルを折りたたむ", "vi": "Thu gọn bảng Agent"},
"structure.expand_agent_tooltip": {
"en": "Click to expand the Agent panel", "ja": "クリックしてエージェントパネルを展開",
"vi": "Bấm để mở lại bảng Agent"},
"structure.agent_header": {"en": "Agent — ask about the graph", "ja": "エージェント ― グラフについて質問", "vi": "Agent — hỏi về đồ thị"},
"structure.ask_placeholder": {
"en": "e.g. what calls main? which files define classes?",
"ja": "例: main を呼んでいるのは?クラスを定義しているファイルは?",
"vi": "vd. cái gì gọi hàm main? file nào định nghĩa class?"},
"structure.ask": {"en": "Ask", "ja": "質問", "vi": "Hỏi"},
"structure.detail_placeholder": {
"en": "Click a node to open its folder, or ask the agent about the graph.",
"ja": "ノードをクリックするとフォルダを開きます。またはエージェントにグラフについて質問できます。",
"vi": "Nhấp node để mở thư mục, hoặc hỏi agent về đồ thị."},
"structure.pick_folder_title": {"en": "Choose folder", "ja": "フォルダを選択", "vi": "Chọn thư mục"},
"structure.scanning": {"en": "Scanning structure…", "ja": "構造をスキャン中…", "vi": "Đang quét cấu trúc…"},
"structure.scan_error": {"en": "Scan error: {err}", "ja": "スキャンエラー: {err}", "vi": "Lỗi khi quét: {err}"},
"structure.graph_summary": {"en": "Graph: {nodes} nodes, {edges} edges.{note}", "ja": "グラフ: ノード {nodes} 個、エッジ {edges} 個。{note}", "vi": "Đồ thị: {nodes} node, {edges} cạnh.{note}"},
"structure.truncated_note": {"en": " (truncated — too many nodes)", "ja": " (切り捨て:ノードが多すぎます)", "vi": " (đã cắt bớt — quá nhiều node)"},
}
@@ -47,6 +47,23 @@ class JsonConfigRepository:
self._env_overrides = env_overrides
self.data: Dict[str, Any] = self._load()
@classmethod
def from_data(cls, data: Dict[str, Any], path: Path):
"""Dựng từ dict có sẵn — KHÔNG đọc đĩa, KHÔNG nâng cấp schema.
Dành cho test: chúng dựng cấu hình trong bộ nhớ rồi mới ghi. Đi qua
``__init__`` thường thì nó đọc file (chưa có) và có thể chạy migration
trên dữ liệu test, tức là test đo nhầm thứ khác.
"""
obj = cls.__new__(cls)
obj._file = AtomicJsonFile(Path(path))
obj._secrets = None
from ... import config as legacy
obj._defaults = legacy.DEFAULT_CONFIG
obj._env_overrides = legacy._apply_env_overrides
obj.data = data
return obj
# ---- nạp ------------------------------------------------------------
def _load(self) -> Dict[str, Any]:
merged = copy.deepcopy(self._defaults)
@@ -74,6 +91,14 @@ class JsonConfigRepository:
def active_provider(self) -> str:
return self.data.get("active_provider", "")
@active_provider.setter
def active_provider(self, name: str) -> None:
"""``AppConfig`` cũ cho gán thẳng, và 3 chỗ trong app.py đang gán. Bỏ
setter đi thì Qt nuốt AttributeError trong slot và triệu chứng là
"bấm không ăn", không có lỗi nào hiện ra — mất hẳn một buổi mới truy
ra. Refactor thì hành vi nhìn từ ngoài phải y hệt."""
self.data["active_provider"] = name
def set_active_provider(self, name: str) -> None:
self.data["active_provider"] = name
@@ -135,6 +160,10 @@ class JsonConfigRepository:
def theme(self) -> str:
return self.data.get("theme", "dark")
@theme.setter
def theme(self, value: str) -> None:
self.data["theme"] = value
def set_theme(self, value: str) -> None:
self.data["theme"] = value
@@ -142,6 +171,10 @@ class JsonConfigRepository:
def language(self) -> str:
return self.data.get("language", "vi")
@language.setter
def language(self, value: str) -> None:
self.data["language"] = value
def set_language(self, value: str) -> None:
self.data["language"] = value
@@ -170,6 +203,164 @@ class JsonConfigRepository:
disabled.append(name)
self.data["tools_disabled"] = disabled
# ---- phần bù để thay được AppConfig ----------------------------------
# 21 thành viên dưới đây chép nguyên ngữ nghĩa từ ``config.py::AppConfig``.
# Không phải thiết kế mới: chừng nào 29 file còn gọi qua ``ctx.config`` thì
# repository phải trả lời được đúng những câu hỏi cũ, nếu không thì không
# tráo được. Dọn lại là việc của các R sau, không phải của R02.
#: Các chế độ định tuyến. Delta thêm "fallback" ở R03-T03. Định nghĩa ở đây
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
@classmethod
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
"""Dựng repository từ đường dẫn mặc định — thay ``AppConfig.load()``."""
if path is None:
from ... import config as legacy
path = legacy.CONFIG_PATH
return cls(Path(path), secrets=secrets)
@property
def path(self) -> Path:
return self._file.path
# ---- TLS -------------------------------------------------------------
@property
def ca_bundle(self) -> str:
"""Đường dẫn file PEM riêng, hoặc '' để kiểm chứng chỉ như bình thường.
Dùng làm tham số ``verify=`` của ``requests`` cho mọi lượt gọi HTTPS."""
return (self.data.get("tls_ca_bundle") or "").strip()
@ca_bundle.setter
def ca_bundle(self, value: str) -> None:
self.data["tls_ca_bundle"] = (value or "").strip()
# ---- MS365 -----------------------------------------------------------
@property
def ms365(self) -> Dict[str, Any]:
return self.data.setdefault("ms365", copy.deepcopy(self._defaults["ms365"]))
def ms365_try_unlock(self, code: str) -> bool:
"""Mở khoá nhóm MS365 trong Cài đặt cho phiên này.
Đây là khoá phía giao diện (chặn bấm nhầm vào một mục nhạy cảm), KHÔNG
phải xác thực Microsoft. Không bao giờ được lưu ở trạng thái đã mở."""
if (code or "") and code == self.ms365.get("unlock_code", ""):
self.data["ms365"]["unlocked"] = True
return True
return False
def ms365_lock(self) -> None:
self.data.setdefault("ms365", {})["unlocked"] = False
# ---- nhóm cấu hình đọc thẳng ------------------------------------------
@property
def code(self) -> Dict[str, Any]:
return self.data["code"]
@property
def teams(self) -> Dict[str, Any]:
return self.data["teams"]
@property
def history(self) -> Dict[str, Any]:
return self.data["history"]
@property
def codebase_memory(self) -> Dict[str, Any]:
return self.data["codebase_memory"]
@property
def cowork(self) -> Dict[str, Any]:
return self.data["cowork"]
@property
def mcp_servers(self) -> list:
return self.data.setdefault("mcp_servers", [])
@property
def structure(self) -> Dict[str, Any]:
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
@property
def monitoring_visibility(self) -> Dict[str, bool]:
return self.data.setdefault(
"monitoring_visibility",
copy.deepcopy(self._defaults["monitoring_visibility"]))
@property
def ext_connectors(self) -> Dict[str, list]:
"""Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other."""
d = self.data.setdefault(
"ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
# ---- công tắc tổng cho connector -------------------------------------
@property
def connect_external(self) -> bool:
"""Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định
BẬT để cấu hình đang chạy không đổi hành vi."""
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
def set_connect_external(self, enabled: bool) -> None:
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
self.save()
# ---- những thứ đã gieo sẵn -------------------------------------------
@property
def seeded_library_skills(self) -> list:
"""Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị
lặng lẽ gieo lại."""
return list(self.data.setdefault("seeded_library_skills", []))
@seeded_library_skills.setter
def seeded_library_skills(self, slugs) -> None:
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
@property
def seeded_builtin_flows(self) -> list:
"""Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người
dùng đã xoá như seeded_library_skills)."""
return list(self.data.setdefault("seeded_builtin_flows", []))
@seeded_builtin_flows.setter
def seeded_builtin_flows(self, ids) -> None:
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
# ---- định tuyến theo từng bề mặt chat --------------------------------
def routing_mode_for(self, surface: str) -> str:
"""Chế độ có hiệu lực cho một bề mặt chat.
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
có người sửa tay file cấu hình."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None:
mode = mode if mode in self.ROUTING_MODES else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
# ---- tiện ích --------------------------------------------------------
def model_label(self) -> str:
return str(self.provider_conf().get("model", "?"))
# ---- ghi -------------------------------------------------------------
def save(self) -> None:
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
@@ -0,0 +1,179 @@
"""Sandbox capability matrix — which isolation backends exist on which OS,
and which one a given risk tier should prefer.
Pure policy/data: no subprocess execution, no PySide6, no dependency on
``core/sandbox_manager.py`` (that module owns the actual execution and isn't
in this task's editable scope — this matrix is a standalone, independently
testable module ready for that module's owner to wire in later).
The Windows entries mirror what ``core/sandbox_manager.py`` +
``core/appcontainer_sandbox.py``/``core/windows_sandbox_vm.py``/
``core/integrity_sandbox.py`` already implement today. Linux/macOS entries
are declared but marked ``implemented=False`` — today those platforms have no
real isolation backend (confirmed: ``core/appcontainer_sandbox.py`` and
``core/windows_sandbox_vm.py`` both hard-return ``False`` off Windows) — so
this matrix reports that honestly instead of pretending capabilities that
don't exist yet. Adding a real Linux/macOS backend later is a 1-line flip of
``implemented`` plus whatever backend module implements it; adding a whole
new OS is a call to :func:`register_profile`, no changes to
:class:`SandboxCapabilityMatrix` itself.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import Dict, Optional, Tuple
# Plain string constants (like core/audit_log.py's ``Kind``) rather than an
# Enum, so a brand-new OS can be registered without editing a closed type.
WINDOWS = "windows"
LINUX = "linux"
MACOS = "macos"
UNKNOWN = "unknown"
# Risk tiers — same vocabulary as security/command_risk_classifier.RiskLevel,
# kept as plain strings here so this module has zero dependency on the
# ``security/`` package (out of scope for this task).
SAFE = "SAFE"
MODERATE = "MODERATE"
HIGH = "HIGH"
CRITICAL = "CRITICAL"
BLOCKED = "blocked"
DIRECT = "direct"
def detect_os(platform_name: Optional[str] = None) -> str:
"""``platform_name`` defaults to ``sys.platform`` but can be injected for
testing (e.g. ``detect_os("linux")``, ``detect_os("darwin")``)."""
name = platform_name if platform_name is not None else sys.platform
if name.startswith("win"):
return WINDOWS
if name.startswith("linux"):
return LINUX
if name.startswith("darwin"):
return MACOS
return UNKNOWN
@dataclass(frozen=True)
class SandboxBackend:
name: str
isolation_level: str # "none" | "resource_limits" | "restricted_token" | "namespace" | "seatbelt" | "full_vm"
implemented: bool # whether a real backend exists today, vs. a declared placeholder
@dataclass(frozen=True)
class OsSandboxProfile:
operating_system: str
backends: Tuple[SandboxBackend, ...]
# risk tier -> ordered list of preferred backend names (first available wins)
routing: Dict[str, Tuple[str, ...]]
def _profile(operating_system: str, backends: Tuple[SandboxBackend, ...],
routing: Dict[str, Tuple[str, ...]]) -> OsSandboxProfile:
return OsSandboxProfile(operating_system=operating_system, backends=backends, routing=routing)
_WINDOWS_PROFILE = _profile(
WINDOWS,
backends=(
SandboxBackend(DIRECT, "none", True),
SandboxBackend("integrity_job_wfp", "resource_limits", True),
SandboxBackend("appcontainer", "restricted_token", True),
SandboxBackend("windows_sandbox", "full_vm", True),
),
routing={
SAFE: ("integrity_job_wfp", DIRECT),
MODERATE: ("integrity_job_wfp", DIRECT),
HIGH: ("appcontainer", "integrity_job_wfp"),
CRITICAL: ("windows_sandbox", "appcontainer", BLOCKED),
},
)
_LINUX_PROFILE = _profile(
LINUX,
backends=(
SandboxBackend(DIRECT, "none", True),
SandboxBackend("namespaces_bubblewrap", "namespace", False), # not implemented yet
),
routing={
SAFE: (DIRECT,),
MODERATE: (DIRECT,),
HIGH: ("namespaces_bubblewrap", BLOCKED),
CRITICAL: (BLOCKED,),
},
)
_MACOS_PROFILE = _profile(
MACOS,
backends=(
SandboxBackend(DIRECT, "none", True),
SandboxBackend("sandbox_exec", "seatbelt", False), # not implemented yet
),
routing={
SAFE: (DIRECT,),
MODERATE: (DIRECT,),
HIGH: ("sandbox_exec", BLOCKED),
CRITICAL: (BLOCKED,),
},
)
_UNKNOWN_PROFILE = _profile(
UNKNOWN,
backends=(),
routing={SAFE: (BLOCKED,), MODERATE: (BLOCKED,), HIGH: (BLOCKED,), CRITICAL: (BLOCKED,)},
)
_PROFILES: Dict[str, OsSandboxProfile] = {
WINDOWS: _WINDOWS_PROFILE,
LINUX: _LINUX_PROFILE,
MACOS: _MACOS_PROFILE,
UNKNOWN: _UNKNOWN_PROFILE,
}
def register_profile(profile: OsSandboxProfile) -> None:
"""Extension point for a brand-new OS: build an :class:`OsSandboxProfile`
and register it once — no change to :class:`SandboxCapabilityMatrix`
needed. Overwrites any existing profile for the same
``operating_system`` name (lets a caller override the built-in Windows/
Linux/macOS profiles too, e.g. once a real Linux backend ships)."""
_PROFILES[profile.operating_system] = profile
class SandboxCapabilityMatrix:
"""Answers, for one OS: which backends are actually available today, and
which one a given risk tier should prefer. Read-only policy — does not
execute anything."""
def __init__(self, operating_system: Optional[str] = None,
allow_direct_fallback: bool = True) -> None:
self.operating_system = operating_system if operating_system is not None else detect_os()
self._profile = _PROFILES.get(self.operating_system, _UNKNOWN_PROFILE)
self.allow_direct_fallback = allow_direct_fallback
def all_backends(self) -> Tuple[SandboxBackend, ...]:
"""Every backend declared for this OS, implemented or not."""
return self._profile.backends
def available_backends(self) -> Tuple[SandboxBackend, ...]:
"""Only backends with a real implementation today."""
return tuple(b for b in self._profile.backends if b.implemented)
def select_backend(self, risk_level: str) -> str:
"""The backend name to use for ``risk_level`` on this OS — the first
available (implemented) backend in that tier's preference order, else
``"direct"`` when allowed for a non-CRITICAL tier, else ``"blocked"``."""
available_names = {b.name for b in self.available_backends()}
preferred = self._profile.routing.get(risk_level.upper(), ())
for name in preferred:
if name == BLOCKED:
return BLOCKED
if name in available_names:
return name
if (self.allow_direct_fallback and DIRECT in available_names
and risk_level.upper() != CRITICAL):
return DIRECT
return BLOCKED
+167
View File
@@ -0,0 +1,167 @@
"""Canonical audit event logging — the infrastructure behind
``core/audit_log.py``'s ``set_identity``/``record``/``load_events`` free
functions (kept as thin wrappers over a module-level singleton for backward
compatibility with every existing call site).
Same on-disk shape as before: one JSON line per event, one file per day
under ``~/.cowork_local/audit/`` (plus a best-effort mirror into a shared
cross-machine folder when an identity's ``shared_dir`` is set). ``record()``
never raises — audit logging must never break a chat turn, a permission
decision, or a tool call.
The event schema is unchanged (same field names, same order) so every
``.jsonl`` file written before this refactor remains fully readable. New
event kinds can be added by defining another ``KIND_*`` constant — nothing
about the schema itself needs to change to support one.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
# Known kinds today. ``kind`` stays a plain str (not an enum) so a caller can
# always pass a new value without editing this module — these constants are
# just the documented, current vocabulary.
KIND_TOOL_CALL = "tool_call"
KIND_PERMISSION = "permission"
KIND_SECURITY_BLOCK = "security_block"
KIND_MCP_CALL = "mcp_call"
@dataclass(frozen=True)
class CanonicalAuditEvent:
"""One audit log entry. Field order matches the pre-refactor
``core/audit_log.py`` schema exactly, for byte-compatible JSON output."""
ts: str
kind: str
agent_role: str
name: str
ok: bool
detail: str
account: str
role: str
machine: str
def to_dict(self) -> Dict[str, Any]:
return {
"ts": self.ts,
"kind": self.kind,
"agent_role": self.agent_role,
"name": self.name,
"ok": self.ok,
"detail": self.detail,
"account": self.account,
"role": self.role,
"machine": self.machine,
}
@classmethod
def from_dict(cls, raw: Dict[str, Any]) -> "CanonicalAuditEvent":
"""Tolerant of missing keys, so old/partial rows never fail to load."""
return cls(
ts=str(raw.get("ts", "")),
kind=str(raw.get("kind", "")),
agent_role=str(raw.get("agent_role", "")),
name=str(raw.get("name", "")),
ok=bool(raw.get("ok", False)),
detail=str(raw.get("detail", "")),
account=str(raw.get("account", "")),
role=str(raw.get("role", "")),
machine=str(raw.get("machine", "")),
)
@dataclass
class _Identity:
account: str = ""
role: str = ""
machine: str = ""
shared_dir: str = ""
class CanonicalAuditLogger:
"""Day-sharded JSONL audit writer/reader. Process identity (who's logged
in, this machine's name) is set once via :meth:`set_identity`, mirroring
the pre-refactor module-global pattern but held as instance state so this
class can be constructed/injected instead of relying on globals."""
def __init__(self, audit_dir: Path):
self.audit_dir = Path(audit_dir)
self._identity = _Identity()
def set_identity(self, account: str, machine: str, role: str = "",
shared_dir: str = "") -> None:
"""Called once after login succeeds. ``shared_dir``, when reachable,
makes every subsequent :meth:`record` ALSO best-effort-append to the
shared cross-machine telemetry store."""
self._identity = _Identity(account=account or "", role=role or "",
machine=machine or "", shared_dir=shared_dir or "")
def record(self, kind: str, name: str, ok: bool, detail: str = "",
agent_role: str = "") -> None:
"""Append one audit event. Never raises."""
try:
now = datetime.now()
event = CanonicalAuditEvent(
ts=now.isoformat(timespec="seconds"),
kind=kind,
agent_role=agent_role or "",
name=name or "",
ok=bool(ok),
detail=(detail or "")[:2000],
account=self._identity.account,
role=self._identity.role,
machine=self._identity.machine,
)
self.audit_dir.mkdir(parents=True, exist_ok=True)
path = self.audit_dir / f"{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
self._write_shared(event, now)
except Exception: # noqa: BLE001
pass
def _write_shared(self, event: CanonicalAuditEvent, now: datetime) -> None:
identity = self._identity
if not identity.shared_dir or not identity.machine:
return
try:
shared = Path(identity.shared_dir).expanduser() / "telemetry" / "audit"
shared.mkdir(parents=True, exist_ok=True)
path = shared / f"{identity.machine}-{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
except Exception: # noqa: BLE001
pass
def load_events(self, start: Optional[date] = None, end: Optional[date] = None,
kind: Optional[str] = None,
directory: Optional[Path] = None) -> List[CanonicalAuditEvent]:
"""Events between ``start``/``end`` (inclusive; None = unbounded),
optionally filtered to one ``kind``."""
directory = directory or self.audit_dir
if not directory.exists():
return []
events: List[CanonicalAuditEvent] = []
for path in sorted(directory.glob("*.jsonl")):
try:
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
except ValueError:
continue
if (start and day < start) or (end and day > end):
continue
try:
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
raw = json.loads(line)
if kind is not None and raw.get("kind") != kind:
continue
events.append(CanonicalAuditEvent.from_dict(raw))
except (OSError, json.JSONDecodeError):
continue
return events
+51
View File
@@ -0,0 +1,51 @@
"""Agent và skill dùng trong flow — R08-T09.
"""
from __future__ import annotations
import re
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from ...core import co4e, skills as skills_mod
from ...i18n import tr
from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EAgentsMixin:
def _new_agent(self) -> None:
self._edit_agent_dialog(co4e.new_custom_agent(""))
def _edit_agent(self) -> None:
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
agent = next((a for a in co4e.list_custom_agents() if a.id == cid), None)
if agent is not None:
self._edit_agent_dialog(agent)
def _edit_agent_dialog(self, agent: co4e.CustomAgent) -> None:
from ...ui.co4e_agent_dialog import Co4EAgentDialog
dlg = Co4EAgentDialog(self.ctx, agent, _skill_names(), self)
if dlg.exec():
co4e.save_custom_agent(dlg.result_agent())
self._reload_sidebar()
def _delete_agent(self) -> None:
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
co4e.delete_custom_agent(cid)
self._reload_sidebar()
def _manage_skills(self) -> None:
from ...ui.skills_dialog import SkillsDialog
SkillsDialog(self, self.ctx).exec()
self._reload_sidebar()
def _skill_map(self) -> Dict[str, str]:
out = {}
for name in _skill_names():
block = skills_mod.skill_prefix_for(name)
if block:
out[name] = block.split("\n", 1)[1] if "\n" in block else block
return out
+343
View File
@@ -0,0 +1,343 @@
"""Khung chat của Co4E và việc đếm token — R08-T09.
Khác chat của Cowork ở một điểm: ở đây câu người dùng gõ có thể mang chỉ thị
chọn agent (``_extract_agent_directive``), và mỗi lượt được định tuyến riêng
theo cấu hình routing của Co4E.
"""
from __future__ import annotations
import re
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QSplitter, QWidget
from ...core import co4e, skills as skills_mod
from ...core.co4e_builtins import BUILTIN_AGENTS
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.chat_view import ChatView
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import ChatPanel
class Co4EChatMixin:
def _build_chat(self) -> QWidget:
"""Widget construction lives in ``ChatPanel`` (presentation/co4e/
co4e_chat_view.py); this method just wires the panel's public
attributes to the handler methods that know about ``self``
(``_toggle_messages``, ``_chat_send``) and keeps the state that is
NOT part of the panel's own construction (``_flow_logs`` — per-flow
ChatView dict, ``_co4e_routed_provider`` — routing override, and
``_vsplit_sizes``/``_msgs_collapsed`` — used by ``_toggle_messages``
below to restore/collapse the splitter) — the panel itself stays
ignorant of ``Co4ETab``.
"""
panel = ChatPanel(self.ctx)
self._chat_widget = panel
self.msgs_icon = panel.msgs_icon
self.msgs_title = panel.msgs_title
self.chat_toggle_btn = panel.chat_toggle_btn
self.chat_toggle_btn.clicked.connect(self._toggle_messages)
self._mhdr = panel.header
self.chat_stack = panel.chat_stack
self._flow_logs: Dict[str, ChatView] = {}
self.chat_input_row = panel.chat_input_row
self._usage_total_lbl = panel.usage_total_lbl
self.chat_input = panel.chat_input
self.chat_input.submit.connect(self._chat_send)
self.chat_send_btn = panel.chat_send_btn
self.chat_send_btn.clicked.connect(self._chat_send)
self.co4e_routing_toggle = panel.co4e_routing_toggle
self._co4e_routed_provider = None # routing provider override for the next turn
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
self._msgs_collapsed = True
return panel
def _toggle_messages(self) -> None:
"""Show/hide the WHOLE chat box (message list + composer) below the
header. Collapsing hands the freed height to the canvas.
A QSplitter's ``setMaximumHeight`` on one side does NOT automatically
redistribute the freed space to the other side — it just shrinks the
splitter's own total height, leaving the canvas frozen at its old size
and blank space below it. So this explicitly calls ``setSizes`` on both
the collapse AND the expand path, computed from the splitter's CURRENT
total (not a hardcoded guess) — that total stays constant; only how
it's split between canvas/chat changes."""
self._msgs_collapsed = not self._msgs_collapsed
collapsed_h = self._mhdr.sizeHint().height() + 6
if self._msgs_collapsed:
if hasattr(self, "_vsplit"):
self._vsplit_sizes = self._vsplit.sizes() # remember to restore
self.chat_stack.hide()
self.chat_input_row.hide()
self._chat_widget.setMaximumHeight(collapsed_h)
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → click to expand
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
if hasattr(self, "_vsplit"):
total = sum(self._vsplit.sizes()) or (self._vsplit_sizes and sum(self._vsplit_sizes)) or 760
self._vsplit.setSizes([max(0, total - collapsed_h), collapsed_h])
else:
self._chat_widget.setMaximumHeight(16777215)
self.chat_stack.show()
self.chat_input_row.show()
self.chat_toggle_btn.setIcon(icon("chevron-down")) # expanded → click to collapse
self.chat_toggle_btn.setToolTip(tr("co4e.tt_collapse_msgs"))
if getattr(self, "_vsplit_sizes", None) and hasattr(self, "_vsplit"):
self._vsplit.setSizes(self._vsplit_sizes)
return
def _ensure_flow_log(self, wf_id: str) -> ChatView:
"""The ChatView for a flow, created + added to the stack on first use so
each flow tab keeps a SEPARATE conversation."""
log = self._flow_logs.get(wf_id)
if log is None:
log = ChatView()
log._co4e_plan_bubble = None # per-flow 'current plan' bubble
self._flow_logs[wf_id] = log
self.chat_stack.addWidget(log)
return log
def _active_log(self) -> ChatView:
wf = getattr(self, "_wf", None)
return self._ensure_flow_log(wf.id if wf is not None else "__none__")
@property
def chat_log(self) -> ChatView:
"""The conversation of the CURRENTLY-shown flow (all append/stream calls
go here). Assignment is not supported — logs are per-flow now."""
return self._active_log()
@property
def _plan_bubble(self):
return getattr(self._active_log(), "_co4e_plan_bubble", None)
@_plan_bubble.setter
def _plan_bubble(self, value) -> None:
self._active_log()._co4e_plan_bubble = value
def _chat_send(self) -> None:
text = self.chat_input.text().strip()
if not text or self._chat_worker is not None:
return
self.chat_input.clear()
self._append_chat("user", text)
skill_prefix, request, info = skills_mod.parse_skill_command(text)
if info is not None:
self._append_chat("system", info)
return
system_parts = []
if skill_prefix:
system_parts.append(skill_prefix)
agent_name, request = self._extract_agent_directive(request)
model = ""
if agent_name:
persona = self._resolve_agent(agent_name)
if persona is None:
self._append_chat("system", tr("co4e.agent_not_found", name=agent_name))
return
system_parts.append(persona[0])
model = persona[1]
# Auto Model Routing — only when the user hasn't pinned an agent's own
# model (an explicit pin wins). May switch provider+model for this turn.
if not model:
model = self._apply_co4e_routing(request)
self._run_chat_turn(system_parts, request, model)
def _apply_co4e_routing(self, request: str) -> str:
"""Route this Co4E turn to the best-fit model. Returns the model id to
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
a cross-provider switch is chosen.
R03-T05: the Off/Auto/Manual/Fallback rules are no longer re-implemented
here — they come from the shared ``RoutingApplicationService``, so Co4E,
the Cowork chat and AI-Edit can never drift apart again. This method only
adapts between Co4E's state and the service's DTOs. Never raises — falls
back to the default model on any error.
"""
self._co4e_routed_provider = None
try:
from ...application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from ...ui.routing_toggle import confirm_switch
cur_provider = self.ctx.config.active_provider
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="co4e",
prompt=request,
current_provider=cur_provider,
current_model=cur_model,
),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return "" # '' keeps the provider's configured default model
# Remembered so the worker's build_provider_for() can follow a
# cross-provider switch, not just a model change.
self._co4e_routed_provider = outcome.provider
self._append_chat("system", tr(
"routing.switched_notice",
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
return outcome.model
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
self._co4e_routed_provider = None
return ""
def _extract_agent_directive(self, text: str):
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
if not m:
return "", text
name = m.group(1)
rest = (text[:m.start()] + " " + text[m.end():]).strip()
return name, rest
def _resolve_agent(self, name: str):
low = name.lower()
for a in BUILTIN_AGENTS:
if a.slug == low or a.name.lower() == low:
return (f"You are the {a.role} agent — {a.name}.\n{a.instructions}", "")
for ca in co4e.list_custom_agents():
if co4e.slugify(ca.name) == low or ca.name.lower() == low:
return (f"You are the {ca.role} agent — {ca.name}.\n{ca.instructions}", ca.model)
return None
def _run_chat_turn(self, system_parts: List[str], request: str, model: str) -> None:
self.chat_send_btn.setEnabled(False)
log = self.chat_log # THIS flow's conversation (captured)
log._co4e_plan_bubble = None # a fresh plan for this turn
ctx = self.ctx
out_dir = self._out_dir()
sys_text = "\n\n".join(p for p in system_parts if p)
prompt = f"{sys_text}\n\n{request}" if sys_text else request
assistant = log.add_assistant() # stream into this live bubble
state = {"text": ""}
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
flow_label = wf.name if wf is not None else "flow"
def job(worker: AgentWorker):
from ...core import agent_roles, usage_tracker as ut
from ...core.chat_agent import run_cowork
from ...core.co4e_runner import _usage_delta
# An Auto/Manual routing switch may target a different provider.
provider = ctx.build_provider_for(getattr(self, "_co4e_routed_provider", None), model or None)
messages = [{"role": "user", "content": prompt}]
ut.set_context("co4e", flow_label) # attribute + measure this turn's usage
ut.begin_accumulation()
base = ut.accumulated()
def _emit(ev):
if not isinstance(ev, dict):
return
t = ev.get("type")
if t == "text":
worker.emit_event({"type": "text", "delta": ev.get("delta", "")})
elif t == "plan_set":
worker.emit_event({"type": "plan_set", "steps": ev.get("steps") or []})
try:
run_cowork(provider, messages, out_dir, _emit, worker.is_cancelled,
security_config=ctx.config, agent_role=agent_roles.COWORK,
run_to_completion=True, enforce_rules=False)
usage = _usage_delta(base, ctx.config)
finally:
ut.end_accumulation()
for m in reversed(messages):
if m.get("role") == "assistant" and m.get("content"):
return {"text": str(m["content"]), "usage": usage}
return {"text": "", "usage": usage}
def on_event(ev):
if ev.get("type") == "text":
state["text"] += ev.get("delta", "")
assistant.set_markdown(state["text"])
log.scroll_to_bottom()
elif ev.get("type") == "plan_set":
self._append_plan(ev.get("steps") or [], log=log)
def done(result: dict):
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
final = result.get("text") or state["text"]
assistant.set_markdown(final or "(no output)")
self._apply_usage(assistant, wf_id, result.get("usage"))
log.scroll_to_bottom()
def failed(err: str):
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
self._append_chat("error", f"[error: {err}]", log=log)
w = AgentWorker(job)
w.event.connect(on_event)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._chat_worker = w
w.start()
def _append_chat(self, role: str, text: str, log: "ChatView" = None) -> None:
"""Add one message bubble to a flow's conversation. ``log`` defaults to the
active flow's log; a run/stream passes its OWN captured log so events land
in the right flow even if the user switches tabs mid-run."""
log = log or self.chat_log
if role == "user":
bub = log.add_user(text)
elif role == "assistant":
bub = log.add_assistant()
bub.set_markdown(text)
elif role == "error":
bub = log.add_error(text)
else: # system status marker
bub = log.add_status(text)
log.scroll_to_bottom()
return bub
def _fmt_usage(self, d_in: int, d_out: int, d_cache: int, cost_usd: float) -> str:
"""The Cowork-style footer string: ↓in ↑out ▤(in+out+cache) $cost, priced
with the Monitoring model-price table in the app's display currency."""
from ...core import model_pricing as mp, usage_tracker as ut
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
return (f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
f"{ut.format_cost(cost_usd, pricing)}")
def _apply_usage(self, bub, wf_id, usage) -> None:
"""Attach a token/cost footer to a step's bubble and add it to the flow's
running total (mirrors Cowork's per-message + conversation-total display)."""
if not isinstance(usage, dict):
return
d_in = int(usage.get("in", 0) or 0)
d_out = int(usage.get("out", 0) or 0)
d_cache = int(usage.get("cache", 0) or 0)
cost = float(usage.get("cost_usd", 0.0) or 0.0)
if bub is not None and (d_in or d_out):
try:
bub.add_usage(self._fmt_usage(d_in, d_out, d_cache, cost))
except Exception: # noqa: BLE001 - a usage footer must never break the run
pass
if wf_id is not None:
tot = self._flow_usage.setdefault(wf_id, {"in": 0, "out": 0, "cache": 0, "cost": 0.0})
tot["in"] += d_in; tot["out"] += d_out; tot["cache"] += d_cache; tot["cost"] += cost
self._refresh_usage_total(wf_id)
def _refresh_usage_total(self, only_wf: str = None) -> None:
"""Update the bottom conversation total to the CURRENT flow's running
usage (skip if the event is for a different, background flow)."""
lbl = getattr(self, "_usage_total_lbl", None)
if lbl is None:
return
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
if only_wf is not None and only_wf != wf_id:
return
tot = self._flow_usage.get(wf_id) if wf_id else None
if not tot or not (tot["in"] or tot["out"]):
lbl.setText("")
return
lbl.setText(self._fmt_usage(int(tot["in"]), int(tot["out"]),
int(tot["cache"]), float(tot["cost"])))
def _append_diff(self, title: str, diff: str, log: "ChatView" = None) -> None:
"""Render a before/after diff as a collapsible colored diff bubble."""
log = log or self.chat_log
log.add_diff(f"▤ {title}", diff)
log.scroll_to_bottom()
def _append_plan(self, steps, log: "ChatView" = None) -> None:
"""Show the plan INLINE in the conversation as an expandable block; update
the same (per-flow) bubble in place so steps tick off (✓) as they complete."""
log = log or self.chat_log
body = _fmt_plan(steps)
if not body:
return
if getattr(log, "_co4e_plan_bubble", None) is None:
log._co4e_plan_bubble = log.add_plan(body)
else:
log._co4e_plan_bubble.set_plain(body)
log.scroll_to_bottom()
+180
View File
@@ -0,0 +1,180 @@
"""Dải tab các flow đang mở — R08-T09.
Mỗi flow người dùng mở là một tab. Đóng tab không đóng flow: nó chỉ rời khỏi
dải, flow vẫn còn trong thư viện bên trái.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import QPushButton, QTabBar
from ...core import co4e
from ...i18n import tr
from ...ui.icons import icon
class Co4EFlowTabsMixin:
def _open_flow(self, wf: co4e.Workflow) -> None:
"""Open ``wf`` in a tab — reuse its tab if already open (like a browser),
else add a new one and switch to it. Bar index 0 is the pinned Runs tab,
so flow ``i`` lives at bar index ``i + 1``. If the flow has an active run,
its live status is reflected on the canvas."""
for i, f in enumerate(self._flows):
if f.id == wf.id:
self._flows[i] = wf
bar_idx = i + 1
self.flow_bar.setTabText(bar_idx, wf.name or tr("co4e.untitled"))
if self.flow_bar.currentIndex() == bar_idx:
self._active_flow_idx = -1 # force reload of same tab
self._on_flow_tab_changed(bar_idx)
else:
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
return
# Without the strip there is nowhere to switch between open flows, so
# opening one REPLACES the one on the canvas (saved first, as the tab
# switch used to do). Runs already in progress are unaffected — they are
# tracked per flow id and keep going in the background.
self._close_other_flows()
self._flows.append(wf)
self.flow_bar.blockSignals(True)
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
self._add_tab_close_button(bar_idx)
self.flow_bar.blockSignals(False)
if self.flow_bar.currentIndex() == bar_idx:
self._on_flow_tab_changed(bar_idx) # already current → load manually
else:
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
def _close_other_flows(self) -> None:
"""Leave the canvas empty of flows, saving whatever was on it.
Called before opening a flow, because the tab strip that used to hold
several at once is gone. Tab 0 (Runs) is never touched.
"""
if not self._flows:
return
if 0 <= self._active_flow_idx < len(self._flows):
self._sync_wf_from_canvas()
self.flow_bar.blockSignals(True)
for idx in range(self.flow_bar.count() - 1, 0, -1):
self.flow_bar.removeTab(idx)
self.flow_bar.blockSignals(False)
self._flows.clear()
self._active_flow_idx = -1
def _show_runs(self, on: bool) -> None:
"""Swap the centre between the flow editor and the Runs table.
This is where the pinned "Runs" tab went when the strip was removed —
same page, same table, reached from a toggle in the flow toolbar.
"""
target = 0 if on else min(1, self.flow_bar.count() - 1)
if self.flow_bar.currentIndex() == target:
self._on_flow_tab_changed(target) # already there → re-apply
else:
self.flow_bar.setCurrentIndex(target)
def _on_flow_tab_changed(self, idx: int) -> None:
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
self._sync_wf_from_canvas()
if idx <= 0: # the Runs page
self._active_flow_idx = -1
self.center_stack.setCurrentIndex(0)
self._sync_runs_toggle(True)
self._refresh_runs()
return
flow_idx = idx - 1
if not (0 <= flow_idx < len(self._flows)):
return
self._active_flow_idx = flow_idx
self.center_stack.setCurrentIndex(1)
self._sync_runs_toggle(False)
self._apply_workflow(self._flows[flow_idx])
def _sync_runs_toggle(self, on: bool) -> None:
"""Keep the Runs toggle showing which page is up, however it got there
(a double-click in the runs table also switches pages)."""
btn = getattr(self, "runs_btn", None)
if btn is not None and btn.isChecked() != on:
blocked = btn.blockSignals(True)
btn.setChecked(on)
btn.blockSignals(blocked)
def _add_tab_close_button(self, idx: int) -> None:
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
the tab's right side, vertically centered and INSIDE the tab (reliable
across themes, unlike the CSS-positioned default which looked detached)."""
btn = QPushButton("×") # ×
btn.setObjectName("flowTabClose")
btn.setFlat(True)
btn.setFixedSize(16, 16)
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda: self._close_flow_tab_button(btn))
self.flow_bar.setTabButton(idx, QTabBar.RightSide, btn)
def _close_flow_tab_button(self, btn) -> None:
for i in range(self.flow_bar.count()):
if self.flow_bar.tabButton(i, QTabBar.RightSide) is btn:
self._close_flow_tab(i)
return
def _close_flow_tab(self, idx: int) -> None:
if idx <= 0: # Runs tab is pinned
return
flow_idx = idx - 1
if not (0 <= flow_idx < len(self._flows)):
return
closing = self._flows[flow_idx]
# Stop mirroring the closed flow's run onto the canvas — the run itself
# keeps going in the background and stays in Flow Status. (Per-flow run
# tracking: only this flow's entry is dropped; other flows keep running.)
rid = self._flow_runs.pop(closing.id, None)
if rid is not None:
self._run_logs.pop(rid, None)
if getattr(self, "_wf", None) is not None and self._wf.id == closing.id:
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._flows.pop(flow_idx)
self.flow_bar.blockSignals(True)
self.flow_bar.removeTab(idx)
self.flow_bar.blockSignals(False)
self._active_flow_idx = -1
if not self._flows:
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
else:
new_bar = min(idx, len(self._flows)) # clamp to the last flow tab
self.flow_bar.blockSignals(True)
self.flow_bar.setCurrentIndex(new_bar)
self.flow_bar.blockSignals(False)
self._on_flow_tab_changed(new_bar)
def _sync_active_flow_tab_text(self) -> None:
i = self.flow_bar.currentIndex()
if i >= 1: # never rename the Runs tab
self.flow_bar.setTabText(i, self._wf.name or tr("co4e.untitled"))
def _reflect_active_run(self, wf_id: str) -> None:
"""If a run for this flow is active, mirror its live node statuses onto the
canvas and keep tracking it so updates continue to show."""
for h in self.manager.all_runs():
if h.wf_id == wf_id and h.running:
self._flow_runs[wf_id] = h.id
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
return
def _cur_run_id(self) -> Optional[str]:
"""The active canvas run of the CURRENTLY-shown flow, or None. Clears a
stale entry if that run already finished."""
wf = getattr(self, "_wf", None)
if wf is None:
return None
rid = self._flow_runs.get(wf.id)
if rid is None:
return None
h = self.manager.get(rid)
if h is None or not h.running:
self._flow_runs.pop(wf.id, None)
return None
return rid
def _outputs_for(self, wf_id: str) -> Dict[str, str]:
"""This flow's accumulated step outputs (kept separate per flow so parallel
runs never seed each other's context)."""
return self._flow_outputs.setdefault(wf_id, {})
def _update_run_btn(self) -> None:
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
else tr("co4e.run"))
+308
View File
@@ -0,0 +1,308 @@
"""Bố cục ba khung và bảng cấu hình node — R08-T09.
Co4E có bốn lớp điều hướng chồng nhau (dải flow, tab icon bên phải, bảng cấu
hình, canvas). Phần quyết định cái nào hiện lúc nào nằm ở đây, tách khỏi phần
hành vi để sửa bố cục không phải đọc logic chạy flow.
``_apply_narrow_layout`` là chỗ đáng chú ý: màn hẹp thì bảng cấu hình chuyển
từ khung cố định sang lớp phủ, vì ba khung cạnh nhau không vừa 1280px.
"""
from __future__ import annotations
import re
from typing import List
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 ...core import co4e
from ...i18n import tr
from ...theme import current_palette
from ...ui.co4e_canvas import Co4ECanvas
from ...ui.icons import icon
from ...presentation.co4e.co4e_run_control_widget import RunsPagePanel
class Co4ELayoutMixin:
def _build_center(self) -> QWidget:
from PySide6.QtWidgets import QStackedWidget, QTabBar
page = QWidget()
lay = QVBoxLayout(page)
# Flow tab bar: a pinned "Runs" tab first (manage every flow run), then a
# browser-style tab per open flow — each keeps its own graph (no mixing).
self.flow_bar = QTabBar()
self.flow_bar.setObjectName("flowTabs")
self.flow_bar.setTabsClosable(True)
self.flow_bar.setMovable(True)
self.flow_bar.setExpanding(False)
self.flow_bar.setDrawBase(False)
# No arrow scroll buttons — when the tabs overflow they scroll inside a
# frameless horizontal scroller you drag left/right (see flow_row below).
self.flow_bar.setUsesScrollButtons(False)
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
# flush, centred). Here we only style the per-tab close (✕) button, which
# QTabBar places centred on the tab's right (see _add_tab_close_button).
_fp = current_palette()
self.flow_bar.setStyleSheet(
"QPushButton#flowTabClose {"
f" border: none; background: transparent; color: {_fp.text_muted};"
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
f" border-radius: {_fp.radius_sm}px; }}"
"QPushButton#flowTabClose:hover {"
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
self.flow_bar.tabCloseRequested.connect(self._close_flow_tab)
# "+" new-flow button styled as the last tab in the strip (browser-style)
# — the + glyph sits inside a tab-shaped button flush with the tabs.
self.flow_add_btn = QPushButton("+")
self.flow_add_btn.setObjectName("flowAddBtn")
self.flow_add_btn.setFixedWidth(34)
self.flow_add_btn.setToolTip(tr("co4e.tt_new_wf"))
self.flow_add_btn.clicked.connect(self._new_workflow)
# Frameless horizontal scroller around the tab strip: overflowing tabs
# scroll (drag) left/right instead of being boxed with arrow buttons.
# The tab bar AND the "+" button are pinned to the SAME fixed height —
# giving the scroll area extra height for its scrollbar (as a previous
# version did) left the tabs top-anchored inside a taller box while the
# "+" button centered across that whole (taller) box, so the two drifted
# out of alignment. Same height on both = always aligned, no centering
# math needed; the scrollbar only appears on overflow (rare) and briefly
# overlaps the tab strip's bottom edge in that case.
_tab_h = self.flow_bar.sizeHint().height()
self.flow_bar.setFixedHeight(_tab_h)
self.flow_add_btn.setFixedHeight(_tab_h)
self.flow_scroll = QScrollArea()
self.flow_scroll.setObjectName("flowTabScroll")
self.flow_scroll.setWidget(self.flow_bar)
self.flow_scroll.setWidgetResizable(True)
self.flow_scroll.setFrameShape(QScrollArea.NoFrame) # no outer frame
self.flow_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.flow_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.flow_scroll.setFixedHeight(_tab_h)
self.flow_scroll.setStyleSheet(
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
# The strip itself is NOT shown any more (see class docstring): flows are
# picked from the WORKFLOWS list on the left, one open at a time. The
# QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
# every open/close/rename path already goes through it — but the user
# never sees or drives it.
self.flow_scroll.setVisible(False)
self.flow_add_btn.setVisible(False)
# Content switches between the Runs table (tab 0) and the flow editor.
self.center_stack = QStackedWidget()
lay.addWidget(self.center_stack, 1)
self.center_stack.addWidget(self._build_runs_page()) # stack 0 = Runs
flow_page = QWidget()
lay = QVBoxLayout(flow_page)
lay.setContentsMargins(0, 0, 0, 0)
bar = QHBoxLayout(); bar.setSpacing(5)
self.name_edit = QLineEdit(self._wf.name)
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
self.name_edit.textChanged.connect(self._on_name_changed)
# "Add" is a labelled button (not a "+" icon) so it isn't mistaken for
# the zoom-in control, which now lives in the canvas's bottom-left overlay.
self.add_step_btn = QPushButton(tr("co4e.add")); self.add_step_btn.setIcon(icon("plus"))
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
self.add_step_btn.clicked.connect(self._add_blank_step)
self.save_btn = QPushButton(tr("co4e.save")); self.save_btn.setIcon(icon("save"))
self.save_btn.setObjectName("primary")
self.save_btn.setToolTip(tr("co4e.tt_save"))
self.save_btn.clicked.connect(lambda: self._save(as_template=False))
self.save_tpl_btn = self._icon_btn("star", "co4e.tt_save_template",
lambda: self._save(as_template=True))
self.mode_combo = QComboBox()
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
for m in co4e.RUN_MODES:
self.mode_combo.addItem(tr(f"co4e.mode.{m}"), m)
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
self.run_btn = QPushButton(tr("co4e.run")); self.run_btn.setIcon(icon("play"))
self.run_btn.setObjectName("primary")
self.run_btn.setToolTip(tr("co4e.tt_run"))
self.run_btn.clicked.connect(self._on_run_clicked)
# The pinned "Runs" tab lost its strip, so it becomes a toggle here —
# one click to the run table and one click back, from either page.
self.runs_btn = QPushButton(tr("co4e.runs_tab"))
self.runs_btn.setIcon(icon("monitoring"))
self.runs_btn.setCheckable(True)
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_btn.toggled.connect(self._show_runs)
bar.addWidget(QLabel(tr("co4e.flow_name")))
bar.addWidget(self.name_edit, 1)
bar.addWidget(self.add_step_btn)
bar.addWidget(self.save_btn)
bar.addWidget(self.save_tpl_btn)
bar.addWidget(self.mode_combo)
bar.addWidget(self.run_btn)
bar.addWidget(self.runs_btn)
lay.addLayout(bar)
self.canvas = Co4ECanvas()
self._build_canvas_overlay()
vsplit = QSplitter(Qt.Vertical)
vsplit.addWidget(self.canvas)
chat_widget = self._build_chat() # default-collapsed (see _build_chat)
vsplit.addWidget(chat_widget)
vsplit.setStretchFactor(0, 1)
self._vsplit = vsplit # so the message panel can collapse/expand
# Messages start collapsed — give the canvas the room from the start,
# not the [540, 220] split that assumed an expanded chat box.
collapsed_h = chat_widget.maximumHeight()
vsplit.setSizes([max(0, 760 - collapsed_h), collapsed_h])
lay.addWidget(vsplit, 1)
self.center_stack.addWidget(flow_page) # stack 1 = flow editor
self.center_stack.setCurrentIndex(1)
return page
def _build_runs_page(self) -> QWidget:
"""The pinned 'Runs' tab: a table of every flow run (name · status · steps
done/total · creator · created) for tracking. Double-click a run to open
that flow's tab with its live status.
Widget construction lives in ``RunsPagePanel`` (presentation/co4e/
co4e_run_control_widget.py); this method just wires the panel's public
attributes to the handler methods that know about ``self`` (``_show_runs``,
``_stop_selected_run``, ...) — the panel itself stays ignorant of ``Co4ETab``.
"""
panel = RunsPagePanel()
self.runs_back_btn = panel.back_btn
self.runs_back_btn.clicked.connect(lambda: self._show_runs(False))
self.runs_title = panel.title_label
self.ws_folder_btn = panel.ws_folder_btn
self.ws_folder_btn.clicked.connect(self._open_workspace_folder)
self._refresh_ws_folder_btn()
self.run_stop_btn = panel.stop_btn
self.run_stop_btn.clicked.connect(self._stop_selected_run)
self.run_rename_btn = panel.rename_btn
self.run_rename_btn.clicked.connect(self._rename_selected_run)
self.run_del_btn = panel.del_btn
self.run_del_btn.clicked.connect(self._delete_selected_run)
self.run_clear_btn = panel.clear_btn
self.run_clear_btn.clicked.connect(lambda: self.manager.clear_finished())
self.runs_table = panel.table
self.runs_table.itemDoubleClicked.connect(self._open_run_from_table)
self.runs_table.customContextMenuRequested.connect(self._runs_context_menu)
return panel
def _wrap_config(self) -> QWidget:
"""Wrap the step-config panel with a header that has an expand/collapse
toggle, so it can be folded away to give the canvas more room."""
container = QWidget()
container.setObjectName("configContainer")
v = QVBoxLayout(container)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
header = QWidget()
hb = QHBoxLayout(header)
hb.setContentsMargins(4, 3, 4, 3)
hb.setSpacing(4)
self.config_toggle_btn = QPushButton()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
self.config_toggle_btn.setFixedSize(26, 24)
self.config_toggle_btn.clicked.connect(self._toggle_config)
self.config_title = QLabel(tr("co4e.config_title"))
self.config_title.setObjectName("hint")
hb.addWidget(self.config_toggle_btn)
hb.addWidget(self.config_title, 1)
v.addWidget(header)
v.addWidget(self.config, 1)
self._cfg_vlayout = v
# Spacers used ONLY while collapsed, to keep the lone toggle icon
# vertically CENTERED in the thin strip (its position no longer jumps to
# the top after collapsing).
self._cfg_top_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self._cfg_bot_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self.config_container = container
return container
def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401
"""Fold the step-config panel on a narrow window, restore it when there
is room again.
Attached from __init__ rather than only on show: this page sits inside a
QTabWidget, whose minimum width is the MAXIMUM over all its pages —
including hidden ones. While Co4E sat unfolded in the background it was
forcing Project and Cowork to be ~1180px wide too.
"""
if narrow != self._config_collapsed:
self._toggle_config()
def _toggle_config(self) -> None:
self._config_collapsed = not self._config_collapsed
v = self._cfg_vlayout
if self._config_collapsed:
w = self.config_container.width()
if w > 60:
self._config_expanded_w = w
self.config.hide()
self.config_title.hide()
self.config_container.setMaximumWidth(34)
self.config_toggle_btn.setIcon(icon("chevron-left"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_expand_config"))
# center the toggle vertically in the collapsed strip
v.insertItem(0, self._cfg_top_spacer)
v.addItem(self._cfg_bot_spacer)
# A maximumWidth alone doesn't make the splitter hand the freed width
# to the canvas — set sizes explicitly so the panel folds to the right.
sizes = self._split.sizes()
if len(sizes) == 3:
freed = sizes[2] - 34
sizes[2] = 34
sizes[1] = max(200, sizes[1] + freed)
self._split.setSizes(sizes)
# Without this the splitter keeps reporting the OLD minimum width,
# and since a QTabWidget's minimum is the maximum over all its pages
# — hidden ones included — Co4E would go on forcing Project and
# Cowork to be 1180px wide even while folded here.
self._refresh_min_width()
else:
v.removeItem(self._cfg_top_spacer)
v.removeItem(self._cfg_bot_spacer)
self.config_container.setMaximumWidth(16777215)
self.config.show()
self.config_title.show()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
sizes = self._split.sizes()
if len(sizes) == 3:
want = self._config_expanded_w
delta = want - sizes[2]
sizes[2] = want
sizes[1] = max(200, sizes[1] - delta)
self._split.setSizes(sizes)
self._refresh_min_width()
def _refresh_min_width(self) -> None:
"""Make the splitter (and everything above it) re-read its minimum."""
self.config_container.updateGeometry()
self._split.refresh()
self._split.updateGeometry()
self.updateGeometry()
def _build_canvas_overlay(self) -> None:
"""Zoom +/− and Fit as a small floating control at the canvas's
bottom-left, stacked vertically. The frame is transparent (so it follows
the dark/light theme — only the buttons carry a themed background) and the
buttons are half-size."""
from PySide6.QtCore import QSize
bar = QFrame()
bar.setObjectName("canvasOverlay")
bar.setStyleSheet("QFrame#canvasOverlay { background: transparent; border: none; }")
v = QVBoxLayout(bar)
v.setContentsMargins(2, 2, 2, 2)
v.setSpacing(3)
self.zoom_in_btn = self._icon_btn("plus", "co4e.tt_zoom_in", lambda: self.canvas.zoom_in())
self.zoom_out_btn = self._icon_btn("minus", "co4e.tt_zoom_out", lambda: self.canvas.zoom_out())
self.fit_btn = self._icon_btn("search", "co4e.fit_tooltip", lambda: self.canvas.fit_view())
for b in (self.zoom_in_btn, self.zoom_out_btn, self.fit_btn):
b.setFixedSize(16, 16) # ~half the previous size
b.setIconSize(QSize(11, 11))
b.setStyleSheet("QPushButton { padding: 0px; }") # keep themed bg, drop padding
v.addWidget(b)
self.canvas.add_overlay(bar)
+364
View File
@@ -0,0 +1,364 @@
"""Chạy flow và bảng lịch sử lượt chạy — R08-T09.
Ba chế độ chạy: cả flow, một node, hoặc từng bước thủ công. ``_topo_order`` và
``_downstream`` là phần đồ thị — chạy node nào trước, node nào phụ thuộc node
nào.
``_on_manager_event`` là nơi mọi tín hiệu từ bộ chạy nền đổ về; nó dài vì phải
phân nhánh theo loại sự kiện, không tách nhỏ được mà không làm khó đọc hơn.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem
from ...core import co4e
from ...i18n import tr
from ...theme import current_palette
class Co4ERunsMixin:
def _current_mode(self) -> str:
return self.mode_combo.currentData() or "auto"
def _on_mode_changed(self, *_a) -> None:
# switching mode resets any in-progress manual sequence
self._manual_active = False
self._manual_order = []
self._manual_idx = 0
if self._cur_run_id() is None:
self.run_btn.setText(tr("co4e.run"))
def _on_run_clicked(self) -> None:
# THIS flow's run is active → interrupt it (other flows keep running).
cur = self._cur_run_id()
if cur is not None:
self.manager.stop(cur)
return
mode = self._current_mode()
if mode == "manual":
self._manual_run_or_advance()
else:
self._start_canvas_run(plan_mode=(mode == "plan"))
def _start_canvas_run(self, *, plan_mode: bool, only: Optional[set] = None,
seed: Optional[Dict[str, str]] = None) -> None:
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
wf_id = self._wf.id
if only is None:
self.canvas.reset_statuses()
self._outputs_for(wf_id).clear()
self._plan_bubble = None
self._append_chat("system", tr("co4e.run_started", name=self._wf.name))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(), plan_mode=plan_mode,
only_nodes=only, seed_outputs=seed or dict(self._outputs_for(wf_id)))
self._flow_runs[wf_id] = run_id # track THIS flow's run (parallel-safe)
self._run_logs[run_id] = self.chat_log # route its events to THIS flow's log
self.run_btn.setText(tr("co4e.interrupt"))
def _run_single(self, node_id: str) -> None:
"""Run one step (config panel "Run this step") with upstream context."""
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only={node_id}, seed=dict(self._outputs_for(self._wf.id)))
def _run_from(self, node_id: str) -> None:
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only=self._downstream(node_id), seed=dict(self._outputs_for(self._wf.id)))
def _downstream(self, node_id: str) -> set:
adj: Dict[str, List[str]] = {}
for e in self.canvas.edges():
adj.setdefault(e.source, []).append(e.target)
seen, stack = set(), [node_id]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
stack.extend(adj.get(cur, []))
return seen
def _manual_run_or_advance(self) -> None:
if not self._manual_active:
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
self.canvas.reset_statuses()
self._outputs_for(self._wf.id).clear()
self._plan_bubble = None
self._manual_order = self._topo_order()
self._manual_idx = 0
self._manual_active = True
self._append_chat("system", tr("co4e.manual_started", name=self._wf.name))
self._manual_step()
def _manual_step(self) -> None:
if self._manual_idx >= len(self._manual_order):
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"))
return
nid = self._manual_order[self._manual_idx]
label = next((n.data.label for n in self.canvas.nodes() if n.id == nid), nid)
self._append_chat("system", tr("co4e.manual_step",
i=self._manual_idx + 1, n=len(self._manual_order), label=label))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(),
plan_mode=False, only_nodes={nid}, seed_outputs=dict(self._outputs_for(self._wf.id)),
manual=True)
self._flow_runs[self._wf.id] = run_id
self._run_logs[run_id] = self.chat_log
self.run_btn.setText(tr("co4e.interrupt"))
def _topo_order(self) -> List[str]:
nodes = self.canvas.nodes()
edges = self.canvas.edges()
waves = co4e.compute_waves(nodes, edges)
y = {n.id: n.y for n in nodes}
return sorted((n.id for n in nodes), key=lambda nid: (waves.get(nid, 0), y.get(nid, 0)))
def _on_manager_event(self, run_id: str, ev: dict) -> None:
# Per-flow routing: every run's events go to ITS OWN flow log (so parallel
# runs never mix), and the canvas mirrors ONLY the run whose flow is the
# one currently shown. Flow Status refreshes on its own via `changed`.
h = self.manager.get(run_id)
run_wf = h.wf_id if h is not None else None
log = self._run_logs.get(run_id) or self.chat_log
shown = getattr(self, "_wf", None) is not None and run_wf == self._wf.id
t = ev.get("type")
if t == "node_status":
if shown:
self.canvas.update_node_status(ev.get("node_id"), ev.get("status"))
elif t == "node_output":
if run_wf is not None:
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
label = ev["node_id"]
if shown:
label = next((n.data.label for n in self.canvas.nodes() if n.id == ev["node_id"]),
ev["node_id"])
elif h is not None and h.wf is not None:
label = next((n.data.label for n in h.wf.nodes if n.id == ev["node_id"]), ev["node_id"])
if ev.get("output"):
bub = self._append_chat("assistant", f"**{label}**\n\n{ev['output']}", log=log)
# Per-message token/cost footer (↓in ↑out ▤ctx $cost), like Cowork.
self._apply_usage(bub, run_wf, ev.get("usage"))
elif t == "node_diff":
self._append_diff(ev.get("title", ""), ev.get("diff", ""), log=log)
elif t == "node_plan":
self._append_plan(ev.get("steps") or [], log=log)
elif t == "node_tool":
if not ev.get("ok", True):
# A single failed tool call isn't a step failure — the agent is told
# to recover and continue, so show it as a neutral notice (not a red
# "Error" that reads like the whole flow crashed).
self._append_chat("system", "⚠ " + tr("co4e.tool_failed", name=ev.get("name", "")), log=log)
elif t in ("run_done", "run_error"):
# Drop THIS flow's run tracking (other flows keep running in parallel).
if run_wf is not None and self._flow_runs.get(run_wf) == run_id:
self._flow_runs.pop(run_wf, None)
self._run_logs.pop(run_id, None)
if self._manual_active and shown:
self._manual_idx += 1
self._manual_step()
else:
if shown:
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"), log=log)
# Clickable link to the output folder so files are one click away.
out = (h.out_dir if h is not None and h.out_dir else "") or str(self._flow_output_root())
try:
log.add_folder_link(out, tr("co4e.open_output_link"))
log.scroll_to_bottom()
except Exception: # noqa: BLE001 - link is a nicety, never fatal
pass
self._notify_run_finished(run_id) # popup: the flow finished
if not shown and h is not None:
self.status_message.emit(tr("co4e.bg_done", name=h.name, status=h.status))
def _notify_run_finished(self, run_id: str) -> None:
"""Show a non-blocking popup when a flow finishes (done / error / stopped),
so the user is notified even if they're on another screen."""
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QMessageBox
if not hasattr(self, "_run_popups"):
self._run_popups = []
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if h.status == "error" else QMessageBox.Information)
box.setWindowTitle(tr("co4e.run_done_title"))
box.setText(tr("co4e.run_done_popup", name=h.name,
status=tr("co4e.status." + h.status)))
box.setStandardButtons(QMessageBox.Ok)
box.setModal(False) # non-blocking notification
box.setAttribute(Qt.WA_DeleteOnClose, True)
box.finished.connect(
lambda _r=0, b=box: self._run_popups.remove(b) if b in self._run_popups else None)
self._run_popups.append(box) # keep a ref so it isn't GC'd
box.show()
def _refresh_runs(self) -> None:
# Rebuild the always-fresh Runs table from the manager (single source of truth).
if not hasattr(self, "runs_table"):
return
p = current_palette()
color = {"running": p.accent, "done": p.success, "error": p.danger,
"stopped": p.text_muted}
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
# chronological insertion order, so reverse it for display).
runs = list(reversed(self.manager.runs()))
t = self.runs_table
# Preserve the selected run across the rebuild by its id (row indices shift
# as runs are added/deleted, so a row-index restore would jump).
sel_item = t.item(t.currentRow(), 0) if t.currentRow() >= 0 else None
sel_id = sel_item.data(Qt.UserRole) if sel_item is not None else None
t.setRowCount(len(runs))
sel_row = -1
for r, h in enumerate(runs):
vals = [f"{dots.get(h.status, '•')} {h.name}", tr("co4e.status." + h.status),
h.progress_text(), h.created_by or "-", h.created_at or "-"]
for c, val in enumerate(vals):
it = QTableWidgetItem(str(val))
if c == 0:
it.setData(Qt.UserRole, h.id)
if c == 1:
it.setForeground(_qcolor(color.get(h.status, p.text)))
t.setItem(r, c, it)
if h.id == sel_id:
sel_row = r
if sel_row >= 0:
t.setCurrentCell(sel_row, 0)
# The sidebar's short run list is the same data — refresh it together.
self._refresh_side_runs()
# Active-run count, on the sidebar heading now that the tab strip is gone.
n = self.manager.active_count()
label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
if hasattr(self, "flow_bar"):
self.flow_bar.setTabText(0, label)
head = (self._sections.get("co4e.runs_tab") or (None,))[0]
if head is not None:
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
def _stop_selected_run(self) -> None:
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.manager.stop_all()
return
self.manager.stop(it.data(Qt.UserRole))
def _delete_selected_run(self) -> None:
"""Delete the selected run from the Flow Status history (a running one is
stopped first). Removes just that single entry."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id) # stop tracking it per-flow if we were
if h is not None and self._flow_runs.get(h.wf_id) == run_id:
self._flow_runs.pop(h.wf_id, None)
self._run_logs.pop(run_id, None)
self.manager.remove(run_id) # emits `changed` → _refresh_runs
def _runs_context_menu(self, pos) -> None:
from PySide6.QtWidgets import QMenu
item = self.runs_table.itemAt(pos)
if item is None:
return
self.runs_table.selectRow(item.row())
menu = QMenu(self)
menu.addAction(tr("co4e.open_run"),
lambda: self._open_run_from_table(self.runs_table.item(item.row(), 0)))
it0 = self.runs_table.item(item.row(), 0)
rid = it0.data(Qt.UserRole) if it0 is not None else None
menu.addAction(tr("co4e.open_output"), lambda: self._open_run_output_folder(rid))
menu.addAction(tr("co4e.rename_run"), self._rename_selected_run)
menu.addAction(tr("co4e.delete_run"), self._delete_selected_run)
menu.exec(self.runs_table.viewport().mapToGlobal(pos))
def _open_run_output_folder(self, run_id) -> None:
"""Open the workspace folder a specific run wrote its files into."""
from ...ui.osutil import open_location
h = self.manager.get(run_id) if run_id else None
path = Path(h.out_dir) if (h is not None and h.out_dir) else self._flow_output_root()
if not path.exists():
path = self._flow_output_root()
try:
path.mkdir(parents=True, exist_ok=True)
except OSError:
pass
open_location(str(path))
def _rename_selected_run(self) -> None:
"""Rename the selected run in Flow Status — updates the run entry AND its
underlying saved flow / open tab so the name stays consistent everywhere."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QInputDialog
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip()
if not ok or not new or new == h.name:
return
self.manager.rename(run_id, new) # run entry + snapshot (→ refresh)
# Keep the underlying saved flow + any open tab in sync.
wf = co4e.get_workflow(h.wf_id)
if wf is not None:
wf.name = new
co4e.save_workflow(wf)
self._reload_sidebar()
for i, f in enumerate(self._flows):
if f.id == h.wf_id:
f.name = new
self.flow_bar.setTabText(i + 1, new)
break
if self._wf.id == h.wf_id and self.name_edit.text() != new:
self.name_edit.setText(new) # updates _wf.name + active tab text
def _run_selected_in_background(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
# Used to jump the sidebar back to the Workflows tab; with one column
# there is nothing to jump to — show the run that just started instead.
self._refresh_side_runs()
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _rerun_run_item(self, item) -> None:
"""Double-click a run in the history → run that flow again (in background)."""
h = self.manager.get(item.data(Qt.UserRole))
if h is None:
return
wf = self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _open_run_from_table(self, item) -> None:
"""Double-click a run row in the Runs tab → open that flow's tab and show
its live status (opens/focuses the tab; _open_flow reflects the run)."""
id_item = self.runs_table.item(item.row(), 0)
if id_item is None:
return
h = self.manager.get(id_item.data(Qt.UserRole))
if h is None:
return
# Prefer the flow the run kept a reference to (works even after its tab was
# closed or if it was never saved); fall back to resolving by id.
wf = getattr(h, "wf", None) or self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self._open_flow(wf)
# reflect this run's step statuses (done/error/running) on the canvas
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
self.status_message.emit(tr("co4e.viewing_flow", name=wf.name))
+251
View File
@@ -0,0 +1,251 @@
"""Cột trái: thư viện workflow, agent, skill — R08-T09.
Bốn mục gập được (WORKFLOWS / AGENTS / SKILLS / FLOW STATUS). Trạng thái gập
của từng mục là thứ người dùng đặt rồi mong nó giữ nguyên, nên nó nằm trong
cấu hình chứ không phải trong widget.
"""
from __future__ import annotations
import re
from typing import List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget
from ...core import co4e, skills as skills_mod
from ...i18n import tr
from ...ui.icons import icon
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.skills_list_panel import SkillsListPanel
class Co4ESidebarMixin:
def _build_sidebar(self) -> QWidget:
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
# at once, so "what can I drag onto the canvas" is answered by looking
# rather than by clicking through three unlabeled tabs.
# A vertical splitter, not a fixed stack: on a short window four stacked
# lists otherwise squeeze down to one visible row each. The splitter
# hands out the available height by weight and lets the user re-balance
# it by dragging; each list keeps a small minimum so none disappears.
self._sections: dict = {}
self.sidebar = QWidget()
outer_col = QVBoxLayout(self.sidebar)
outer_col.setContentsMargins(6, 6, 6, 6)
outer_col.setSpacing(0)
self.side_split = QSplitter(Qt.Vertical)
self.side_split.setChildrenCollapsible(False)
self.side_split.setHandleWidth(8)
outer_col.addWidget(self.side_split, 1)
class _Col:
"""Adapter so the section builders below read the same as before."""
def __init__(self, split):
self._split = split
def addWidget(self, w, stretch=1):
self._split.addWidget(w)
self._split.setStretchFactor(self._split.count() - 1, stretch)
col = _Col(self.side_split)
# --- WORKFLOWS ---------------------------------------------------
self.wf_new_btn = QPushButton(tr("co4e.new"))
self.wf_new_btn.setIcon(icon("plus"))
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
self.wf_new_btn.setObjectName("co4eSectionAction")
self.wf_new_btn.setFlat(True)
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
self.wf_new_btn.clicked.connect(self._new_workflow)
wf_body = QWidget(); wl = QVBoxLayout(wf_body)
wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
# double-click loads it onto the canvas.
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
self.wf_list.setToolTip(tr("co4e.drag_hint"))
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
wl.addWidget(self.wf_list, 1)
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
wf_btns.addWidget(b)
wf_btns.addStretch(1)
wl.addLayout(wf_btns)
# 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.
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
wl.addWidget(self.wf_runbg_btn)
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
# --- AGENTS ------------------------------------------------------
# Widget cua khu vuc nay da doi sang AgentListPanel (xem
# presentation/co4e/agent_list_panel.py); o day chi con giu
# ag_new_btn/agent_list/ag_edit_btn/ag_del_btn nhu 4 ten thuoc tinh cu
# va tu noi signal - dung nguyen tac panel khong tu wire, Co4ETab moi
# biet _new_agent/_edit_agent/_delete_agent.
self._agent_panel = AgentListPanel()
self.ag_new_btn = self._agent_panel.new_btn
self.ag_new_btn.clicked.connect(self._new_agent)
self.agent_list = self._agent_panel.list_widget
self.ag_edit_btn = self._agent_panel.edit_btn
self.ag_edit_btn.clicked.connect(self._edit_agent)
self.ag_del_btn = self._agent_panel.del_btn
self.ag_del_btn.clicked.connect(self._delete_agent)
col.addWidget(self._section("co4e.tab_agents", self._agent_panel, self.ag_new_btn), 3)
# --- SKILLS ------------------------------------------------------
# Widget cua khu vuc nay da doi sang SkillsListPanel (xem
# presentation/co4e/skills_list_panel.py); o day chi con giu
# sk_manage_btn/skill_list nhu 2 ten thuoc tinh cu va tu noi signal -
# dung nguyen tac panel khong tu wire, Co4ETab moi biet _manage_skills.
self._skills_panel = SkillsListPanel()
self.sk_manage_btn = self._skills_panel.manage_btn
self.sk_manage_btn.clicked.connect(self._manage_skills)
self.skill_list = self._skills_panel.list_widget
col.addWidget(self._section("co4e.tab_skills", self._skills_panel, self.sk_manage_btn), 2)
# --- RUNS --------------------------------------------------------
# A short, always-visible view of the same runs the Flow Status page
# tables in full. Clicking one opens that page with the run selected.
# Icon only: the heading beside it already reads FLOW STATUS, and the
# label was long enough to be cut in half in a narrow sidebar.
self.runs_more_btn = QPushButton()
self.runs_more_btn.setIcon(icon("chevron-right"))
self.runs_more_btn.setFixedWidth(30)
self.runs_more_btn.setFlat(True)
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
self.runs_side_list = QListWidget()
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
rl.addWidget(self.runs_side_list, 1)
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
# Small enough that all four still fit on a laptop screen, large enough
# that each shows more than a single row.
for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
lst.setMinimumHeight(56)
return self.sidebar
def _refresh_side_runs(self) -> None:
"""Mirror the newest runs into the sidebar's short list."""
lst = getattr(self, "runs_side_list", None)
if lst is None:
return
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
lst.clear()
for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
f" {h.progress_text()}")
it.setData(Qt.UserRole, h.id)
it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
lst.addItem(it)
def _on_side_run_clicked(self, item) -> None:
"""Open the full Flow Status page with this run selected."""
run_id = item.data(Qt.UserRole)
self._show_runs(True)
for r in range(self.runs_table.rowCount()):
cell = self.runs_table.item(r, 0)
if cell is not None and cell.data(Qt.UserRole) == run_id:
self.runs_table.setCurrentCell(r, 0)
break
def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
stretch: int = 1) -> QWidget:
"""One named, foldable section of the sidebar column.
Replaces the three icon-only tabs: all the lists are visible at once
(WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
action that belongs to it. Clicking the heading folds the section, so a
narrow window can still get to everything.
"""
box = QWidget()
v = QVBoxLayout(box)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(2)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
head = QPushButton()
head.setObjectName("co4eSectionHdr")
head.setCheckable(True)
head.setChecked(True)
head.setCursor(Qt.PointingHandCursor)
head.setFlat(True)
head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
row.addWidget(head, 1)
if action is not None:
row.addWidget(action, 0)
v.addLayout(row)
v.addWidget(body, 1)
self._sections[key] = (head, body, stretch)
self._sync_section_arrow(key)
return box
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.
Inside a splitter, hiding the body is not enough — the pane keeps its
share of the height, so folding would free nothing. Clamping the whole
section to its header height makes the splitter re-deal the space.
"""
body.setVisible(on)
if on:
box.setMaximumHeight(16777215)
else:
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
self._sync_section_arrow(key)
def _sync_section_arrow(self, key: str) -> None:
head, _body, _s = self._sections[key]
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
b.setFixedWidth(34)
b.clicked.connect(slot)
return b
def _reload_sidebar(self) -> None:
self.wf_list.clear()
for wf in co4e.list_workflows():
tag = tr("co4e.template") if wf.is_template else tr("co4e.saved")
it = QListWidgetItem(icon("workspaces"), f"{wf.name} · {tag}")
it.setData(Qt.UserRole, ("saved", wf.id))
it.setData(Qt.UserRole + 2, {"kind": "workflow", "workflow": co4e.workflow_to_dict(wf)})
self.wf_list.addItem(it)
# Agents: only the Parallel fan-out node + the user's own custom agents
# (create your own with "+ New agent"; drag onto the canvas). The blank
# "New Step" palette entry was removed — use the toolbar "+ Add" instead.
self.agent_list.clear()
self.agent_list.addItem(self._palette_item(
tr("co4e.parallel_node"), "server",
{"variant": "parallel", "label": "Parallel", "role": "PARALLEL", "icon": "server",
"sub_agents": []}))
for ca in co4e.list_custom_agents():
step = co4e.Step(label=ca.name, agent_slug=co4e.slugify(ca.name), role=ca.role or "AGENT",
icon=ca.icon, instructions=ca.instructions,
context=getattr(ca, "context", ""), model=ca.model,
permission_preset=ca.permission_preset, skills=list(ca.skills),
attachments=list(getattr(ca, "attachments", []) or []))
it = self._palette_item(f"{ca.name} · {ca.role} · {tr('co4e.custom')}", ca.icon or "robot",
co4e._step_dict(step))
it.setData(Qt.UserRole + 1, ca.id)
self.agent_list.addItem(it)
# Skills
self.skill_list.clear()
for name in _skill_names():
content = skills_mod.skill_prefix_for(name)
payload = co4e._step_dict(co4e.Step(
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
instructions=content, skills=[name]))
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
@staticmethod
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
it = QListWidgetItem(icon(icon_name), text)
it.setData(Qt.UserRole, payload)
return it
+154
View File
@@ -0,0 +1,154 @@
"""Tạo, sửa, đổi tên, xoá, nhân bản workflow — R08-T09.
Chỉ thao tác trên danh sách. Phần chạy một workflow nằm ở ``co4e_runs.py``,
phần vẽ node nằm ở canvas.
"""
from __future__ import annotations
import re
from typing import List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu
from ...core import co4e
from ...i18n import tr
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EWorkflowCrudMixin:
def _apply_workflow(self, wf: co4e.Workflow) -> None:
self._wf = wf
# Per-flow outputs are kept in self._flow_outputs[wf.id] — do NOT clear
# here (switching tabs must not wipe another flow's accumulated context).
# Switch the visible conversation to THIS flow's own log.
self.chat_stack.setCurrentWidget(self._ensure_flow_log(wf.id))
self.name_edit.setText(wf.name)
self.canvas.load(wf.nodes, wf.edges)
self.config.clear_step()
if wf.nodes:
self.canvas.relayout_if_vertical() # convert old top-down flows to left→right
self.canvas.fit_view()
self._update_run_btn() # reflect THIS flow's run state
self._refresh_usage_total() # show THIS flow's token/cost total
def _new_workflow(self) -> None:
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
# Pressing this while the canvas already holds an empty untitled flow
# produced an identical empty untitled flow — correct, and completely
# invisible, so the button read as broken. Say what happened and put the
# cursor where the next thing to do is: naming it.
self.name_edit.setFocus()
self.name_edit.selectAll()
self.status_message.emit(tr("co4e.new_flow_ready"))
def _selected_wf(self) -> Optional[co4e.Workflow]:
"""Materialise the selected saved-flow row into a Workflow."""
item = self.wf_list.currentItem()
if item is None:
return None
_kind, ident = item.data(Qt.UserRole)
return co4e.get_workflow(ident)
def _load_selected_workflow(self, *_a) -> None:
wf = self._selected_wf()
if wf is not None:
self._open_flow(wf) # open (or focus) its browser-style tab
def _edit_selected_workflow(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self._open_flow(wf)
def _duplicate_selected_workflow(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
dup = co4e.duplicate_workflow(wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.duplicated_msg", name=dup.name))
def _wf_context_menu(self, pos) -> None:
lw = self.wf_list
item = lw.itemAt(pos)
if item is None:
return
lw.setCurrentItem(item)
_kind, ident = item.data(Qt.UserRole)
menu = QMenu(lw)
act_edit = menu.addAction(icon("edit"), tr("co4e.edit"))
act_rename = menu.addAction(icon("edit"), tr("co4e.rename"))
act_dup = menu.addAction(icon("branch"), tr("co4e.duplicate"))
act_run = menu.addAction(icon("play"), tr("co4e.run_bg"))
act_del = menu.addAction(icon("trash"), tr("co4e.delete"))
chosen = menu.exec(lw.viewport().mapToGlobal(pos))
if chosen is act_edit:
self._edit_selected_workflow()
elif chosen is act_rename:
self._rename_workflow(ident)
elif chosen is act_dup:
self._duplicate_selected_workflow()
elif chosen is act_run:
self._run_selected_in_background()
elif chosen is act_del:
self._delete_selected_workflow()
def _rename_workflow(self, ident: str) -> None:
"""Rename a saved flow in place (e.g. to match its function/task)."""
wf = co4e.get_workflow(ident)
if wf is None:
return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name = (name or "").strip()
if not ok or not name:
return
wf.name = name
co4e.save_workflow(wf)
if self._wf.id == ident:
self.name_edit.setText(name)
self._wf.name = name
self._reload_sidebar()
self.status_message.emit(tr("co4e.renamed_msg", name=name))
def _delete_selected_workflow(self) -> None:
item = self.wf_list.currentItem()
if item is None:
return
_kind, ident = item.data(Qt.UserRole)
co4e.delete_workflow(ident)
self._reload_sidebar()
def _sync_wf_from_canvas(self) -> None:
self._wf.nodes = self.canvas.nodes()
self._wf.edges = self.canvas.edges()
self._wf.name = self.name_edit.text().strip() or tr("co4e.untitled")
def _save(self, as_template: bool) -> None:
self._sync_wf_from_canvas()
self._wf.is_template = as_template
co4e.save_workflow(self._wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.saved_msg", name=self._wf.name))
def _autosave(self) -> None:
if co4e.get_workflow(self._wf.id) is not None:
self._sync_wf_from_canvas()
co4e.save_workflow(self._wf)
def _on_name_changed(self, text: str) -> None:
self._wf.name = text.strip() or tr("co4e.untitled")
self._sync_active_flow_tab_text()
def _add_blank_step(self) -> None:
self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center()))
def _on_node_selected(self, node_id: str) -> None:
for n in self.canvas.nodes():
if n.id == node_id:
self.config.load_step(node_id, n.data, _skill_names())
if self._config_collapsed:
self._toggle_config()
return
def _on_config_changed(self) -> None:
for n in self.canvas.nodes():
self.canvas.refresh_node(n.id)
self._autosave()
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
"""Resolve a flow id to a Workflow — saved, or the open canvas."""
wf = co4e.get_workflow(wf_id)
if wf is not None:
return wf
if self._wf.id == wf_id:
self._sync_wf_from_canvas()
return self._wf
return None
+260
View File
@@ -0,0 +1,260 @@
"""Monitoring Dashboard — container only. Builds the tab strip, wires the
auto-refresh timer and language-change retranslation, and forwards nav-rail
sub-tab selection. Each sub-tab is its own class under ``tabs/``; this class
owns no display logic of its own beyond assembling and refreshing them.
Public API preserved exactly for ``app.py`` (which cannot be modified):
``MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)``,
the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
``hide_tab_bar()``.
"""
from __future__ import annotations
from typing import List
from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWidget
from ...core import audit_log
from ...i18n import on_language_changed, tr
from ...state import AppContext
from .tabs.action_logs_tab import ActionLogsTab
from .tabs.agent_status_tab import AgentStatusTab
from .tabs.mcp_tab import McpTab
from .tabs.overview_tab import OverviewTab
from .tabs.security_events_tab import SecurityEventsTab
_REFRESH_MS = 3000
# Comfortably larger than any realistic audit-log size — the event tables
# have never had pagination controls, so every tab still shows "all matching
# events" exactly like before; MonitoringQueryService's pagination support
# is exercised for real here, just not surfaced as UI (yet).
_UNBOUNDED_PAGE_SIZE = 100_000
class MonitoringTab(QWidget):
status_message = Signal(str)
# ---- cầu tương thích sau khi tách 8 tab (R08-T08) --------------------
# Trước khi tách, 55 control của Tổng quan treo thẳng trên MonitoringTab với
# tiền tố ov_. Tách xong mỗi cái về đúng tab/thẻ của nó và rụng tiền tố.
#
# tools/check_controls_alive.py so với mốc git 291a611 và đòi cả 55 cái phải
# còn tới được — đó chính là việc của nó: bắt control biến mất trong lúc bóc
# tách. Lần này control còn đủ, chỉ đổi chỗ ở, nên bắc cầu theo tiền tố.
#
# Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card
# có những tên trùng nhau (network_lbl nằm ở cả hai — một cái là mức dùng
# mạng, một cái là quyền truy cập mạng). Dò mờ vớ nhầm cái đầu tiên tìm thấy.
_OV_TIEN_TO = (
("ov_perm_", lambda s: s.overview_tab.sandbox_card.permissions_card),
("ov_sbx_", lambda s: s.overview_tab.sandbox_card),
("ov_price_", lambda s: s.overview_tab.pricing_panel),
("ov_pricing_", lambda s: s.overview_tab.pricing_panel),
("ov_", lambda s: s.overview_tab),
)
#: Ba hộp nhóm: bản thân widget con CHÍNH LÀ hộp đó, không phải thuộc tính.
_OV_CHINH_NO = {
"ov_pricing_group": lambda s: s.overview_tab.pricing_panel,
"ov_sandbox_details_group": lambda s: s.overview_tab.sandbox_card,
"ov_permissions_group": lambda s: s.overview_tab.sandbox_card.permissions_card,
}
#: Vài control không mang tiền tố ov_ nhưng cũng đã dời đi.
_KHAC = {
"status_table": lambda s: s.status_tab.table,
}
def __getattr__(self, name):
lay_khac = self._KHAC.get(name)
if lay_khac is not None:
return lay_khac(self)
# Qt gọi __getattr__ rất nhiều lúc khởi tạo; chặn sớm cho rẻ.
if not name.startswith("ov_"):
raise AttributeError(name)
lay = self._OV_CHINH_NO.get(name)
if lay is not None:
return lay(self)
for tien_to, chu in self._OV_TIEN_TO:
if name.startswith(tien_to):
try:
return getattr(chu(self), name[len(tien_to):])
except AttributeError:
continue
raise AttributeError(name)
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
super().__init__()
self.ctx = ctx
self._cowork = cowork
self._structure = structure
self._task_scheduler = task_scheduler
root = QVBoxLayout(self)
head = QHBoxLayout()
self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;")
head.addWidget(self._title)
head.addStretch(1)
root.addLayout(head)
self.tabs = QTabWidget()
root.addWidget(self.tabs, 1)
visible = self._tab_visible
self.overview_tab = OverviewTab(
ctx, on_status_message=self.status_message.emit,
on_settings_changed=self.refresh,
on_view_all_action_logs=self._show_action_logs_tab,
action_logs_tab_visible=visible("action_logs"))
self.tabs.addTab(self.overview_tab, "")
self.security_tab = SecurityEventsTab(ctx, on_refresh_all=self.refresh)
if visible("security_events"):
self.tabs.addTab(self.security_tab, "")
self.mcp_tab = McpTab(ctx, on_refresh_all=self.refresh)
if visible("mcp_history"):
self.tabs.addTab(self.mcp_tab, "")
self.action_tab = ActionLogsTab(ctx, on_refresh_all=self.refresh)
if visible("action_logs"):
self.tabs.addTab(self.action_tab, "")
self.status_tab = AgentStatusTab(
ctx, on_refresh_all=self.refresh,
cowork=cowork, structure=structure, task_scheduler=task_scheduler)
if visible("agent_status"):
self.tabs.addTab(self.status_tab, "")
# ---- Agents Admin (catalog: assign a role + pinned model per agent) --
from ...ui.agents_admin_tab import AgentsAdminTab
self.agents_admin_tab = AgentsAdminTab(ctx)
if visible("agents_admin"):
self.tabs.addTab(self.agents_admin_tab, "")
# ---- Tools (govern built-in tools + Connectors/MCP in one place) -----
from ...ui.tools_admin_tab import ToolsAdminTab
self.tools_admin_tab = ToolsAdminTab(ctx)
if visible("tools_admin"):
self.tabs.addTab(self.tools_admin_tab, "")
# ---- Icons (browse built-in icons + add custom icons for agents/flows) --
from ...ui.icons_admin_tab import IconsAdminTab
self.icons_admin_tab = IconsAdminTab(ctx)
self.tabs.addTab(self.icons_admin_tab, "")
self.tabs.setCurrentIndex(0)
self._timer = QTimer(self)
# Only the Overview cards auto-refresh on this tick — Security, MCP,
# Action Logs, Agent Status (and the admin tabs) are read-only tables
# a background re-sort would otherwise disturb mid-interaction; the
# user refreshes them explicitly via a "Refresh" button.
self._timer.setInterval(_REFRESH_MS)
self._timer.timeout.connect(self._auto_refresh)
self._timer.start()
on_language_changed(self._retranslate)
self.refresh()
# ---- nav integration: sub-tabs driven from the left nav rail ------------
def nav_subtabs(self):
"""(label, index, icon_name) for each sub-tab — the left nav lists
these as children under 'Monitoring'."""
by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench",
self.icons_admin_tab: "star"}
for attr, name in (("security_tab", "shield"), ("mcp_tab", "plug"),
("action_tab", "bolt"), ("status_tab", "monitor")):
w = getattr(self, attr, None)
if w is not None:
by_widget[w] = name
out = []
for i in range(self.tabs.count()):
out.append((self.tabs.tabText(i), i, by_widget.get(self.tabs.widget(i), "dashboard")))
return out
def select_subtab(self, index: int) -> None:
if 0 <= index < self.tabs.count():
self.tabs.setCurrentIndex(index)
def hide_tab_bar(self) -> None:
"""Hide the in-content tab strip; the nav rail drives the sub-tabs."""
self.tabs.tabBar().hide()
def _show_action_logs_tab(self) -> None:
self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_tab))
def _tab_visible(self, key: str) -> bool:
return self.ctx.role == "admin" or bool(self.ctx.config.monitoring_visibility.get(key, True))
def _set_tab_text_if_present(self, widget, text: str) -> None:
idx = self.tabs.indexOf(widget)
if idx >= 0:
self.tabs.setTabText(idx, text)
# ---- i18n ---------------------------------------------------------------
def _retranslate(self) -> None:
self._title.setText(tr("monitoring.title"))
if self.tabs.count():
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
self._set_tab_text_if_present(self.security_tab, tr("monitoring.tab_security"))
self._set_tab_text_if_present(self.mcp_tab, tr("monitoring.tab_mcp"))
self._set_tab_text_if_present(self.action_tab, tr("monitoring.tab_actions"))
self._set_tab_text_if_present(self.status_tab, tr("monitoring.tab_agents"))
self._set_tab_text_if_present(self.agents_admin_tab, tr("monitoring.tab_agents_admin"))
self._set_tab_text_if_present(self.tools_admin_tab, tr("monitoring.tab_tools"))
self._set_tab_text_if_present(self.icons_admin_tab, tr("monitoring.tab_icons"))
self.overview_tab.retranslate()
self.security_tab.retranslate()
self.mcp_tab.retranslate()
self.action_tab.retranslate()
self.status_tab.retranslate()
self.refresh()
# ---- refresh -------------------------------------------------------------
def refresh(self) -> None:
"""Full refresh — Overview cards plus every table. Wired to the
top-of-page and per-section "Refresh" buttons, called once at
startup/language-change, but NOT to the auto-refresh timer (see
``_auto_refresh``)."""
events = self._load_events()
self._apply_events_to_event_tabs(events)
self.status_tab.refresh()
self.overview_tab.refresh(events)
def _auto_refresh(self) -> None:
"""3-second timer tick — Overview cards only (see ``refresh``)."""
self.overview_tab.refresh(self._load_events())
def _load_events(self) -> List[dict]:
shared_dir = self.ctx.config.shared_dir
if shared_dir:
from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir)
if shared_events:
return shared_events
return audit_log.load_events()
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
shared local-or-shared decision per refresh, not one per tab) three
ways via :class:`MonitoringQueryService`, mirroring what each
``EventTable.set_events`` used to receive directly."""
from ...application.monitoring.dto.audit_event_dto import AuditEventDTO
from ...application.monitoring.monitoring_query_service import MonitoringQueryService
from ...application.monitoring.repository.audit_event_repository import (
InMemoryAuditEventRepository,
)
repository = InMemoryAuditEventRepository([AuditEventDTO.from_raw(e) for e in events])
service = MonitoringQueryService(repository)
def _events_for(kind) -> List[dict]:
page = service.query(kind=kind, page_size=_UNBOUNDED_PAGE_SIZE)
return [e.to_dict() for e in page.items]
self.security_tab.set_events(_events_for("security_block"))
self.mcp_tab.set_events(_events_for("mcp_call"))
self.action_tab.set_events(_events_for(None))
@@ -0,0 +1,45 @@
"""AI-assisted search-keyword filter — shared by the Security Events / MCP
Call History / Action Logs tabs' search box. Extracted verbatim from
``ui/monitoring_tab.py``'s ``MonitoringTab._ai_filter``.
Each caller owns its own ``state`` dict (just ``{}`` at construction) so a
repeat click is a no-op while a request is already in flight — mirrors the
original single ``self._ai_filter_worker`` attribute, without needing a
shared base class.
"""
from __future__ import annotations
from PySide6.QtWidgets import QLineEdit, QPushButton
def start_ai_filter(ctx, search: QLineEdit, ai_btn: QPushButton, state: dict) -> None:
query = search.text().strip()
if not query or state.get("worker") is not None:
return
ai_btn.setEnabled(False)
def job(worker):
provider = ctx.build_active_provider()
reply = provider.chat([
{"role": "system", "content":
"Turn the user's natural-language question about an audit/security event "
"log into ONE short search keyword. Reply with ONLY the keyword."},
{"role": "user", "content": query},
], cancel=worker.is_cancelled)
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
def done(result: dict) -> None:
state["worker"] = None
ai_btn.setEnabled(True)
search.setText(result.get("keyword") or query)
def failed(_err: str) -> None:
state["worker"] = None
ai_btn.setEnabled(True)
from ....core.worker import AgentWorker
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
state["worker"] = w
w.start()
+90
View File
@@ -0,0 +1,90 @@
"""Badge/label vocabulary shared by the Monitoring event tables and detail
panel — human labels for a raw audit ``name``, and the (QSS object name,
i18n key) pair for the "Trạng thái"/"Mức độ" pills.
``apply_badge`` replaces the two byte-identical
``_EventDetailPanel._apply_badge`` / ``MonitoringTab._set_badge`` static
methods the original file duplicated.
"""
from __future__ import annotations
from typing import Tuple
from PySide6.QtWidgets import QLabel
from ....i18n import tr
# Human label for the raw ``name`` an audit event is recorded under — the
# "Loại" field in the detail panel. Anything not in this map (custom tool
# names, etc.) just shows its raw name, same as the table's Hành động column.
_ACTION_LABEL_KEYS = {
"prompt": "monitoring.action_prompt",
"dangerous_command": "monitoring.action_dangerous_command",
"run_command": "monitoring.action_dangerous_command",
"install_package": "monitoring.action_install_package",
"path_outside_sandbox": "monitoring.action_path_outside_sandbox",
"network_blocked": "monitoring.action_network_blocked",
"secret_in_output": "monitoring.action_secret_in_output",
}
def action_label(name: str) -> str:
key = _ACTION_LABEL_KEYS.get(name)
return tr(key) if key else name
# (badge QSS object name, i18n key) for the "Trạng thái" pill, mapped onto
# the app's existing badge* tones (theme.py).
_STATUS_INFO = {
"path_outside_sandbox": ("badgeSuccess", "monitoring.status_path"),
"network_blocked": ("badge", "monitoring.status_network"),
"secret_in_output": ("badgeWarn", "monitoring.status_secret"),
}
_STATUS_DEFAULT = ("badgePurple", "monitoring.status_blocked")
def status_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
if name in _STATUS_INFO:
return _STATUS_INFO[name]
if kind == "security_block":
# Security Events rows are always ok=False — an unmapped name here
# still means "blocked by some rule", never a plain failure.
return _STATUS_DEFAULT
# MCP calls / generic Action Logs rows: no fixed enforcement-rule
# vocabulary applies, so fall back to the event's own ok/fail outcome.
return ("badgeSuccess", "monitoring.status_ok") if ok else ("badgeDanger", "monitoring.status_failed")
def severity_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
# An unapproved shell command is the one CRITICAL case; everything else
# blocked is MEDIUM.
if name in ("dangerous_command", "run_command"):
return "badgeDanger", "monitoring.severity_critical"
if kind == "security_block":
return "badgeWarn", "monitoring.severity_medium"
# A successful MCP call / action is routine (INFO); a failed one still
# deserves the same MEDIUM tone Security Events uses for a blocked rule.
return ("badge", "monitoring.severity_info") if ok else ("badgeWarn", "monitoring.severity_medium")
def agent_badge_name(name: str) -> str:
"""Badge tone for the Agent field's pill — the same identity-colour
mapping ``formatters.agent_avatar_colour`` uses, expressed as one of the
shared badge* QSS classes (theme.py) instead of a literal hex."""
if "Security" in name:
return "badgeDanger"
if "Cowork" in name:
return "badge"
if name == "schedule":
return "badgeWarn"
if name == "graphrag":
return "badgePurple"
if "Code" in name:
return "badgeSuccess"
return "badge"
def apply_badge(label: QLabel, object_name: str) -> None:
label.setObjectName(object_name)
label.style().unpolish(label)
label.style().polish(label)
@@ -0,0 +1,189 @@
"""Right-hand "Chi tiet su kien" detail panel shared by the Security Events /
MCP Call History / Action Logs tabs — the full record behind whichever row is
selected in an :class:`~.event_table.EventTable`. Extracted verbatim from
``ui/monitoring_tab.py``.
"""
from __future__ import annotations
from typing import Dict, List, Tuple
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
from ....core import agent_roles
from ....i18n import tr
from ....ui.icons import icon
from .badges import agent_badge_name, action_label, apply_badge, severity_info, status_info
from .formatters import event_id, fmt_event_time_full
from .layout_helpers import kv_row
# Cosmetic label only — no real policy-versioning system exists yet.
_STATIC_POLICY_LABEL = "security_policy_v2"
class EventDetailPanel(QWidget):
"""Laid out as three labelled sections, a terminal-style block quote for
the detail text, and a metadata footer, closed by the header close
button, the footer button, Esc, or a click outside the table/panel (see
:class:`~.event_table.ClickOutsideCloser`)."""
closed = Signal()
def __init__(self):
super().__init__()
self.setObjectName("monSection")
self._detail_text = ""
outer = QVBoxLayout(self)
hdr = QHBoxLayout()
self._title_lbl = QLabel()
self._title_lbl.setStyleSheet("font-weight:700;")
hdr.addWidget(self._title_lbl, 1)
self._close_btn = QPushButton()
self._close_btn.setIcon(icon("close"))
self._close_btn.setFlat(True)
self._close_btn.setFixedWidth(28)
self._close_btn.setCursor(Qt.PointingHandCursor)
self._close_btn.clicked.connect(self.closed.emit)
hdr.addWidget(self._close_btn)
outer.addLayout(hdr)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
body = QWidget()
self._body_lay = QVBoxLayout(body)
self._body_lay.setContentsMargins(0, 0, 4, 0)
scroll.setWidget(body)
outer.addWidget(scroll, 1)
self._section_hdrs: List[Tuple[str, QLabel]] = []
self._rows: Dict[str, Tuple[QLabel, QLabel]] = {}
def _section(key: str) -> None:
lbl = QLabel()
lbl.setObjectName("detailSectionHdr")
self._body_lay.addWidget(lbl)
self._section_hdrs.append((key, lbl))
def _field(key: str) -> QLabel:
lbl, val = kv_row(self._body_lay)
self._rows[key] = (lbl, val)
return val
_section("general")
_field("time")
self._agent_val = _field("agent")
_field("account")
self._machine_val = _field("machine")
self._machine_val.setObjectName("monoChip")
_section("action")
self._type_val = _field("type")
self._type_val.setObjectName("neutralTag")
self._status_val = _field("status")
_section("block")
code_box = QWidget()
code_box.setObjectName("detailCodeBlock")
code_lay = QHBoxLayout(code_box)
code_lay.setContentsMargins(8, 6, 8, 6)
self._code_text = QLabel()
self._code_text.setObjectName("detailCodeText")
self._code_text.setWordWrap(True)
self._code_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
code_lay.addWidget(self._code_text, 1)
self._copy_btn = QPushButton()
self._copy_btn.setObjectName("detailCopyBtn")
self._copy_btn.setCursor(Qt.PointingHandCursor)
self._copy_btn.clicked.connect(self._copy_detail)
code_lay.addWidget(self._copy_btn, 0, Qt.AlignVCenter)
self._body_lay.addWidget(code_box)
_section("metadata")
self._event_id_val = _field("event_id")
self._event_id_val.setObjectName("monoChip")
self._policy_val = _field("policy")
self._severity_val = _field("severity")
self._body_lay.addStretch(1)
footer = QHBoxLayout()
footer.setContentsMargins(0, 6, 0, 0)
self._footer_close_btn = QPushButton()
self._footer_close_btn.setObjectName("primary")
self._footer_close_btn.setCursor(Qt.PointingHandCursor)
self._footer_close_btn.clicked.connect(self.closed.emit)
footer.addWidget(self._footer_close_btn, 1)
outer.addLayout(footer)
def retranslate(self) -> None:
self._title_lbl.setText(tr("monitoring.security_detail_title"))
self._close_btn.setToolTip(tr("monitoring.security_detail_close"))
self._footer_close_btn.setText(tr("monitoring.security_detail_close"))
self._footer_close_btn.setIcon(icon("close"))
section_keys = {
"general": "monitoring.detail_section_general",
"action": "monitoring.detail_section_action",
"block": "monitoring.col_detail_block",
"metadata": "monitoring.detail_section_metadata",
}
for key, lbl in self._section_hdrs:
lbl.setText(tr(section_keys[key]).upper())
self._rows["time"][0].setText(tr("monitoring.col_time"))
self._rows["agent"][0].setText(tr("monitoring.col_agent"))
self._rows["account"][0].setText(tr("monitoring.col_account"))
self._rows["machine"][0].setText(tr("monitoring.col_machine"))
self._rows["type"][0].setText(tr("monitoring.detail_type"))
self._rows["status"][0].setText(tr("monitoring.detail_status"))
self._rows["event_id"][0].setText(tr("monitoring.detail_event_id"))
self._rows["policy"][0].setText(tr("monitoring.detail_policy"))
self._rows["severity"][0].setText(tr("monitoring.detail_severity"))
if not self._copy_btn.text() or self._copy_btn.text() != tr("monitoring.detail_copied"):
self._reset_copy_btn()
def show_event(self, ev: dict, row: int) -> None:
na = "—"
self._rows["time"][1].setText(fmt_event_time_full(ev.get("ts", "")) or na)
agent_label = agent_roles.label_for(ev.get("agent_role", "")) or na
self._agent_val.setText(agent_label)
apply_badge(self._agent_val,
agent_badge_name(agent_label) if agent_label != na else "badge")
self._rows["account"][1].setText(ev.get("account", "") or na)
self._machine_val.setText(ev.get("machine", "") or na)
name = ev.get("name", "")
kind = ev.get("kind", "security_block")
ok = ev.get("ok", False)
self._type_val.setText(action_label(name) or na)
status_badge, status_key = status_info(name, kind, ok)
self._status_val.setText(tr(status_key))
apply_badge(self._status_val, status_badge)
self._detail_text = ev.get("detail", "") or na
self._code_text.setText(self._detail_text)
self._reset_copy_btn()
self._event_id_val.setText(event_id(ev.get("ts", ""), row))
# The static policy label names a real enforcement ruleset — only
# meaningful for a Security Events row; MCP calls/generic actions
# were never evaluated against it.
self._policy_val.setText(_STATIC_POLICY_LABEL if kind == "security_block" else na)
severity_badge, severity_key = severity_info(name, kind, ok)
self._severity_val.setText(tr(severity_key))
apply_badge(self._severity_val, severity_badge)
def _copy_detail(self) -> None:
QGuiApplication.clipboard().setText(self._detail_text)
self._copy_btn.setText(tr("monitoring.detail_copied"))
self._copy_btn.setIcon(icon("check"))
QTimer.singleShot(1500, self._reset_copy_btn)
def _reset_copy_btn(self) -> None:
self._copy_btn.setText(tr("monitoring.detail_copy"))
self._copy_btn.setIcon(icon("document"))
@@ -0,0 +1,171 @@
"""Read-only audit-event table shared by the Security Events / MCP Call
History / Action Logs tabs, plus the click-outside-closes-detail-panel event
filter. Extracted verbatim from ``ui/monitoring_tab.py``.
"""
from __future__ import annotations
from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from ....core import agent_roles
from ....i18n import tr
from ....theme import current_palette
from ....ui.icons import DOT_GREEN, DOT_RED, icon
from .badges import action_label
from .formatters import agent_avatar_icon, fmt_event_time
_MAX_ROWS = 300
class _TimeItem(QTableWidgetItem):
"""The Time column shows "dd/MM hh:mm", which does not sort correctly as
text (day-of-month leads, not year/month) — so sorting compares the raw
ISO ``ts`` each item is built from instead of its displayed text."""
def __init__(self, raw_ts: str, display: str):
super().__init__(display)
self._raw_ts = raw_ts
def __lt__(self, other):
if isinstance(other, _TimeItem):
return self._raw_ts < other._raw_ts
return super().__lt__(other)
class EventTable(QTableWidget):
"""A read-only table of audit-log events — newest-first by default, and
every column header is click-to-sort (ascending/descending toggle; the
Time column sorts by the underlying ISO timestamp, not its "dd/MM hh:mm"
display text — see :class:`_TimeItem`)."""
# What each blocked action is, as a colour. Security events all record
# ok=False, so the tick/cross column said the same thing on every row; the
# useful distinction is WHICH rule fired.
_ACTION_TINTS = {
"prompt": "accent",
"dangerous_command": "danger",
"run_command": "danger",
"install_package": "warning",
"path_outside_sandbox": "success",
"network_blocked": "accent",
"secret_in_output": "warning",
}
def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS).
self._show_result = show_result
super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows)
self.setIconSize(QSize(20, 20))
self.verticalHeader().setVisible(False)
# Fixed row height — letting Qt auto-size rows from content fought with
# the action column's cell widget geometry settling stale/oversized on
# an intermediate sizing pass, clipping the pill's text.
self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.verticalHeader().setDefaultSectionSize(32)
self.setSortingEnabled(True)
header = self.horizontalHeader()
header.setStretchLastSection(True)
for col in range(self.columnCount() - 1):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
def retranslate(self) -> None:
cols = [tr("monitoring.col_time"),
tr("monitoring.col_agent") if not self._show_result else tr("monitoring.col_role"),
tr("monitoring.col_account"), tr("monitoring.col_machine")]
if self._show_result:
cols += [tr("monitoring.col_name"), tr("monitoring.col_result")]
else:
cols += [tr("monitoring.col_action")]
cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")]
self.setHorizontalHeaderLabels(cols)
def set_events(self, events: List[dict]) -> None:
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
self.setSortingEnabled(False)
self.setRowCount(len(events))
for row, ev in enumerate(events):
is_admin_violation = ev.get("role") == "admin" and not ev.get("ok", True)
cells = [
ev.get("ts", ""), agent_roles.label_for(ev.get("agent_role", "")),
ev.get("account", "") or "—", ev.get("machine", "") or "—",
ev.get("name", ""),
]
if self._show_result:
cells.append("")
cells.append((ev.get("detail") or "")[:300])
pal = current_palette()
for col, text in enumerate(cells):
item = (_TimeItem(str(text), fmt_event_time(str(text))) if col == 0
else QTableWidgetItem(str(text)))
if col == 0:
# Stash the full event (untruncated detail included) on the
# Time cell, so a click-to-open detail panel survives the
# user re-sorting the table by any column.
item.setData(Qt.UserRole, ev)
if col == 1:
item.setIcon(agent_avatar_icon(str(text)))
if self._show_result and col == 5:
item.setIcon(icon("check", color=DOT_GREEN) if ev.get("ok")
else icon("close", color=DOT_RED))
if not self._show_result and col == 4:
# Human-readable label, tinted by which rule fired, via the
# ITEM's own colours — NOT a setCellWidget() pill, which is
# pinned to a screen position rather than travelling with
# the item across a re-sort.
tint = getattr(pal, self._ACTION_TINTS.get(
ev.get("name", ""), "text_muted"), pal.text_muted)
item.setText(action_label(str(text)))
colour = QColor(tint)
item.setForeground(QBrush(colour))
soft = QColor(colour)
soft.setAlpha(38)
item.setBackground(QBrush(soft))
if is_admin_violation:
item.setBackground(QBrush(QColor(229, 72, 77, 60)))
self.setItem(row, col, item)
self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", ""))
def apply_filter(self, needle: str) -> None:
self._filter_needle = (needle or "").strip().lower()
for row in range(self.rowCount()):
if not self._filter_needle:
self.setRowHidden(row, False)
continue
match = any(
self._filter_needle in (self.item(row, col).text().lower()
if self.item(row, col) else "")
for col in range(self.columnCount()))
self.setRowHidden(row, not match)
def event_at_row(self, row: int) -> Optional[dict]:
item = self.item(row, 0)
return item.data(Qt.UserRole) if item else None
class ClickOutsideCloser(QObject):
"""Closes the event-detail panel on a click anywhere outside the
table/panel splitter — judged by screen-space geometry (is the click's
global position inside the splitter's on-screen rectangle), not by which
exact widget object received the event (unreliable mid-drag on the
splitter's handle)."""
def __init__(self, table: "EventTable", panel: QWidget, container: QWidget):
super().__init__(container)
self._table = table
self._panel = panel
self._container = container
def eventFilter(self, obj, event) -> bool:
if event.type() == QEvent.MouseButtonPress and self._panel.isVisible():
global_pos = event.globalPosition().toPoint()
top_left = self._container.mapToGlobal(self._container.rect().topLeft())
rect = QRect(top_left, self._container.size())
if not rect.contains(global_pos):
self._table.clearSelection()
return False
@@ -0,0 +1,115 @@
"""Page scaffolding shared by the Security Events / MCP Call History / Action
Logs / Agent Status tabs: title + refresh button, optional search box +
AI-filter button, optional table+detail-panel split. Extracted from
``ui/monitoring_tab.py``'s ``MonitoringTab._wrap_with_filter``/
``_sync_event_detail`` — those used to be rebuilt 3 times almost identically
for the 3 event-table pages; this is the one shared implementation.
Builds directly into a caller-supplied ``page`` widget (which must not have a
layout yet) and returns a dict of the sub-widgets the caller needs to keep
(e.g. to implement its own ``retranslate()``).
"""
from __future__ import annotations
from typing import Callable, Dict, Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter,
QTableWidget, QVBoxLayout, QWidget,
)
from ....i18n import tr
from ....ui.icons import icon
from .event_table import ClickOutsideCloser, EventTable
from .event_detail_panel import EventDetailPanel
def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
# currentRow() alone is not enough: clearSelection() (used by the panel's
# close button) drops the selection but leaves the current cell in
# place, so a stale currentRow() would keep the panel open.
row = table.currentRow()
has_selection = bool(table.selectedItems())
ev = table.event_at_row(row) if (has_selection and row >= 0) else None
if ev:
panel.show_event(ev, row)
panel.setVisible(bool(ev))
def build_filter_scaffold(
page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None],
title_key: Optional[str] = None, with_search: bool = True,
with_detail: bool = False,
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
) -> Dict[str, object]:
lay = QVBoxLayout(page)
lay.setContentsMargins(0, 0, 0, 0)
parts: Dict[str, object] = {}
if title_key:
hdr = QHBoxLayout()
title_lbl = QLabel(tr(title_key))
title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
hdr.addWidget(title_lbl)
hdr.addStretch(1)
refresh_btn = QPushButton(tr("monitoring.refresh"))
refresh_btn.setIcon(icon("refresh"))
refresh_btn.setObjectName("primary")
refresh_btn.setCursor(Qt.PointingHandCursor)
refresh_btn.clicked.connect(on_refresh)
hdr.addWidget(refresh_btn)
lay.addLayout(hdr)
parts.update(title_lbl=title_lbl, title_key=title_key, title_refresh_btn=refresh_btn)
if with_search:
row = QHBoxLayout()
search = QLineEdit()
search.setPlaceholderText(tr("monitoring.filter_placeholder"))
search.textChanged.connect(table.apply_filter)
ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
ai_btn.setIcon(icon("sparkle"))
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip"))
ai_btn.setCursor(Qt.PointingHandCursor)
if on_ai_filter is not None:
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
row.addWidget(search, 1)
row.addWidget(ai_btn)
lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
if with_detail:
# Click a row -> its full record opens in a detail panel on the
# right (the table itself clips the detail text to 300 chars). A
# pointing-hand cursor over the rows signals that they're clickable.
table.setCursor(Qt.PointingHandCursor)
detail = EventDetailPanel()
detail.setVisible(False)
detail.closed.connect(table.clearSelection)
table.itemSelectionChanged.connect(lambda: _sync_event_detail(table, detail))
split = QSplitter(Qt.Horizontal)
split.addWidget(table)
split.addWidget(detail)
split.setStretchFactor(0, 1)
split.setStretchFactor(1, 0)
split.setChildrenCollapsible(False)
split.setSizes([700, 320])
lay.addWidget(split, 1)
parts["detail_panel"] = detail
# Esc, anywhere focus is inside this page, closes the panel the same
# way the close button does.
esc = QShortcut(QKeySequence(Qt.Key_Escape), page)
esc.setContext(Qt.WidgetWithChildrenShortcut)
esc.activated.connect(table.clearSelection)
parts["detail_esc_shortcut"] = esc
# A click outside both the table and the panel also closes it.
click_filter = ClickOutsideCloser(table, detail, split)
QApplication.instance().installEventFilter(click_filter)
parts["detail_click_filter"] = click_filter
else:
lay.addWidget(table, 1)
return parts
@@ -0,0 +1,113 @@
"""Formatting helpers shared by the Monitoring tabs — timestamps, byte
counts, and the per-agent colour-coded initials avatar.
Extracted from ``ui/monitoring_tab.py`` verbatim (same output for the same
input); the three timestamp formatters used to each repeat their own
``datetime.fromisoformat`` + ``try/except (TypeError, ValueError)`` guard —
that parse step is now a single shared ``_parse_iso`` helper.
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from ....i18n import tr
def _parse_iso(ts: str) -> Optional[datetime]:
try:
return datetime.fromisoformat(ts)
except (TypeError, ValueError):
return None
def fmt_bytes(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
return f"{n:.1f} TB"
def fmt_event_time(ts: str) -> str:
""""dd/MM hh:mm" for the Time column — e.g. 25/05 15:03."""
dt = _parse_iso(ts)
return dt.strftime("%d/%m %H:%M") if dt else ts
_MIDDLE_DOT = chr(0xB7)
def fmt_event_time_full(ts: str) -> str:
""""dd/MM/yyyy [middle dot] HH:mm:ss" — the detail panel's Thoi gian field."""
dt = _parse_iso(ts)
return dt.strftime(f"%d/%m/%Y {_MIDDLE_DOT} %H:%M:%S") if dt else ts
def relative_time(ts: str) -> str:
"""A short "Xm ago"-style string for an audit-log ``ts``; "" if
unparsable."""
dt = _parse_iso(ts)
if dt is None:
return ""
delta = (datetime.now() - dt).total_seconds()
if delta < 60:
return tr("monitoring.time_just_now")
if delta < 3600:
return tr("monitoring.time_minutes_ago", n=int(delta // 60))
if delta < 86400:
return tr("monitoring.time_hours_ago", n=int(delta // 3600))
return tr("monitoring.time_days_ago", n=int(delta // 86400))
def event_id(ts: str, row: int) -> str:
"""A display-only id in the ``evt_<timestamp digits>_<row>`` shape the
mockup uses — the real audit log has no native event id, so this is
derived from the timestamp and the row's position in the currently
displayed (sorted) table, not persisted anywhere."""
digits = "".join(ch for ch in ts if ch.isdigit())[:12]
return f"evt_{digits}_{row:03d}"
def agent_initials(name: str) -> str:
"""First letter of each word, max 2."""
return "".join(w[0] for w in name.split() if w)[:2].upper()
def agent_avatar_colour(name: str) -> str:
"""A fixed identity colour per agent kind, unchanged by theme."""
if "Security" in name:
return "#D13438"
if "Cowork" in name:
return "#0078D4"
if name == "schedule" or "Task" in name:
return "#FFB900"
if name == "graphrag" or "Knowledge" in name:
return "#8764B8"
if "Code" in name:
return "#107C10"
if "Planner" in name or "Reasoning" in name:
return "#8A8886"
return "#0078D4"
def agent_avatar_icon(name: str, size: int = 20) -> QIcon:
"""A small round initials badge for the Agent column."""
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(agent_avatar_colour(name)))
p.drawEllipse(0, 0, size, size)
font = QFont()
font.setPixelSize(max(7, size // 2))
font.setBold(True)
p.setFont(font)
p.setPen(QColor("#FFFFFF"))
p.drawText(pm.rect(), Qt.AlignCenter, agent_initials(name))
p.end()
return QIcon(pm)
@@ -0,0 +1,27 @@
"""``kv_row`` — the "label — stretch — value" row shape that
``ui/monitoring_tab.py`` used to redefine as 3 byte-identical local closures
(Sandbox Details' ``_kv``, Permissions' ``_pkv``, the detail panel's
``_field``). Overview's Resource Usage row (``_pair``) is a genuinely
different layout (inline on one horizontal line with "·" separators, no
stretch) and is intentionally NOT folded into this helper — unifying it would
risk a visible layout change.
"""
from __future__ import annotations
from typing import Tuple
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout
def kv_row(outer_layout: QVBoxLayout, hint_object_name: str = "hint") -> Tuple[QLabel, QLabel]:
"""Appends a new ``label — stretch — value`` row to ``outer_layout``.
Returns ``(label, value)``."""
row = QHBoxLayout()
label = QLabel()
label.setObjectName(hint_object_name)
value = QLabel()
row.addWidget(label)
row.addStretch(1)
row.addWidget(value)
outer_layout.addLayout(row)
return label, value
@@ -0,0 +1,17 @@
"""Opens the Settings dialog and runs a callback afterward — shared by the
Sandbox Details and Permissions cards' "Edit" buttons, both of which used to
call the identical ``MonitoringTab._open_settings_and_refresh``.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtWidgets import QWidget
def open_settings_and_notify(ctx, parent: QWidget, on_changed: Callable[[], None]) -> None:
from ....ui.settings_dialog import SettingsDialog
dlg = SettingsDialog(ctx, parent)
dlg.exec()
on_changed()
@@ -0,0 +1,45 @@
"""Action Logs tab — the full audit log, newest first. Extracted from
``ui/monitoring_tab.py``'s action-table wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class ActionLogsTab(QWidget):
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
self.table = EventTable()
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.action_logs_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
self.table.set_events(events)
def retranslate(self) -> None:
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,92 @@
"""Agent Status tab — which agent roles are currently running, read from the
existing ``ChatPanel``/``TaskScheduler``/GraphRAG-ask-worker state (no new
runtime tracking of its own). Extracted from ``ui/monitoring_tab.py``'s
status-table wiring + ``_refresh_agent_status``.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from ....core import agent_roles
from ....i18n import tr
from ....ui.widgets import badge_pill_widget
from ..shared.filter_scaffold import build_filter_scaffold
from ..shared.formatters import agent_avatar_icon
class AgentStatusTab(QWidget):
def __init__(self, ctx, on_refresh_all: Callable[[], None],
cowork=None, structure=None, task_scheduler=None):
super().__init__()
self._ctx = ctx
self._cowork = cowork
self._structure = structure
self._task_scheduler = task_scheduler
self.table = QTableWidget(0, 3)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
# No detail to open on click, no filtering — a plain read-only table,
# so selection is off rather than left dangling with no effect.
self.table.setSelectionMode(QTableWidget.NoSelection)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.table.setColumnWidth(1, 130) # status is a cell widget — size it explicitly
self.table.setIconSize(QSize(20, 20))
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.agent_status_title", with_search=False, with_detail=False)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
def retranslate(self) -> None:
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.table.setHorizontalHeaderLabels([
tr("monitoring.col_agent"), tr("monitoring.detail_status"), tr("monitoring.col_source"),
])
def refresh(self) -> None:
cowork_n = len(self._cowork.active_workers()) if self._cowork is not None else 0
task_n = self._task_scheduler.running_count() if self._task_scheduler is not None else 0
ask_worker = getattr(self._structure, "_ask_worker", None)
knowledge_n = 1 if (ask_worker is not None and ask_worker.isRunning()) else 0
# Security is a system-management agent that runs INLINE on the
# active turn (agent_security prompt/command validation) — there is
# no separate worker to count, so its "active" cell shows On/Off
# from Settings instead of a live count.
sec_on = bool(self._ctx.config.agent_security.get("enabled"))
rows = [
(agent_roles.COWORK, cowork_n, tr("monitoring.source_cowork")),
(agent_roles.TASK, task_n, tr("monitoring.source_task")),
(agent_roles.KNOWLEDGE, knowledge_n, tr("monitoring.source_knowledge")),
(agent_roles.PLANNER, None, tr("monitoring.source_planner")),
(agent_roles.REASONING, None, tr("monitoring.source_reasoning")),
(agent_roles.SECURITY, None, tr("monitoring.source_security")),
]
self.table.setRowCount(len(rows))
for row, (role_key, count, source) in enumerate(rows):
label = agent_roles.label_for(role_key)
name_item = QTableWidgetItem(label)
name_item.setIcon(agent_avatar_icon(label))
self.table.setItem(row, 0, name_item)
if role_key == agent_roles.SECURITY:
running = sec_on
status_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
elif count is not None:
running = count > 0
status_text = tr("monitoring.active_n", n=count) if running else tr("monitoring.idle")
else:
running = False
status_text = "—"
badge_tone = "badgeSuccess" if running else "badgeNeutral"
self.table.setCellWidget(row, 1, badge_pill_widget(status_text, badge_tone))
self.table.setItem(row, 2, QTableWidgetItem(source))
+45
View File
@@ -0,0 +1,45 @@
"""MCP Call History tab — the audit log filtered to ``kind="mcp_call"``.
Extracted from ``ui/monitoring_tab.py``'s MCP-table wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class McpTab(QWidget):
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
self.table = EventTable()
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.mcp_history_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
self.table.set_events(events)
def retranslate(self) -> None:
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,313 @@
"""Overview tab — the card-based dashboard (Token Usage & Cost, Resource
Usage, Recent Activity, Sandbox Details + nested Permissions, Model Pricing,
Audit Log preview). Extracted from ``ui/monitoring_tab.py``'s
``_build_overview_page`` and the refresh/pricing/budget methods it wires to.
"""
from __future__ import annotations
import time
from typing import Callable, List
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QGridLayout, QGroupBox, QHBoxLayout, QLabel, QProgressBar, QPushButton,
QScrollArea, QVBoxLayout, QWidget,
)
from ....core import usage_tracker as ut
from ....i18n import tr
from ....theme import current_palette
from ....ui.icons import DOT_AMBER, DOT_GREEN, DOT_RED, icon
from ....ui.widgets import BudgetCard, StatCard, fmt_tokens
from ..shared.formatters import fmt_bytes, relative_time
from .pricing_panel import PricingPanel
from .sandbox_tab import SandboxDetailsCard
class OverviewTab(QWidget):
def __init__(self, ctx, *, on_status_message: Callable[[str], None],
on_settings_changed: Callable[[], None],
on_view_all_action_logs: Callable[[], None],
action_logs_tab_visible: bool):
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
self._on_view_all_action_logs = on_view_all_action_logs
self._last_io_sample = None
self._res_first = True
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
content = QWidget()
scroll.setWidget(content)
outer.addWidget(scroll)
# ONE main column, scrolled vertically, sections in a fixed order.
root = QVBoxLayout(content)
root.setSpacing(12)
self._build_usage_section(root)
self._build_activity_section() # added to `root` further down, beside the audit log
self._build_resource_section(root)
self.sandbox_card = SandboxDetailsCard(ctx, on_settings_changed)
root.addWidget(self.sandbox_card)
self.pricing_panel = PricingPanel(ctx, on_status_message)
root.addWidget(self.pricing_panel)
root.addWidget(self.activity_group)
self._build_audit_section(root, action_logs_tab_visible)
root.addStretch(1)
# ---- Token Usage & Cost ------------------------------------------------
def _build_usage_section(self, root: QVBoxLayout) -> None:
self.usage_group = QGroupBox()
self.usage_group.setObjectName("monSection")
usage_lay = QGridLayout(self.usage_group)
usage_lay.setSpacing(8)
self.usage_total = StatCard()
self.usage_in = StatCard()
self.usage_out = StatCard()
self.usage_cache = StatCard()
self.usage_cost = StatCard()
self.usage_calls = StatCard()
for i, card in enumerate((self.usage_cost, self.usage_total,
self.usage_in, self.usage_out, self.usage_cache)):
usage_lay.addWidget(card, 0, i)
self.usage_calls.setVisible(False) # rides on the cost tile's label
self.budget_card = BudgetCard()
self.budget_card.apply_btn.setIcon(icon("check"))
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
usage_lay.addWidget(self.budget_card, 0, 3)
for col in range(4):
usage_lay.setColumnStretch(col, 1)
root.addWidget(self.usage_group)
# ---- Recent Activity ----------------------------------------------------
def _build_activity_section(self) -> None:
self.activity_group = QGroupBox()
self.activity_group.setObjectName("monSection")
act_lay = QVBoxLayout(self.activity_group)
self.activity_lbl = QLabel()
self.activity_lbl.setWordWrap(True)
self.activity_lbl.setTextFormat(Qt.RichText)
act_lay.addWidget(self.activity_lbl)
# ---- Resource Usage -------------------------------------------------------
def _build_resource_section(self, root: QVBoxLayout) -> None:
self.resource_group = QGroupBox()
self.resource_group.setObjectName("monSection")
res_lay = QHBoxLayout(self.resource_group)
res_lay.setSpacing(6)
def _pair():
if not self._res_first:
sep = QLabel(chr(0xB7))
sep.setObjectName("hint")
res_lay.addWidget(sep)
self._res_first = False
lbl = QLabel()
lbl.setObjectName("hint")
val = QLabel()
res_lay.addWidget(lbl)
res_lay.addWidget(val)
return lbl, val
def _bar_row():
lbl, val = _pair()
bar = QProgressBar()
bar.setVisible(False)
return lbl, bar, val
self.cpu_lbl, self.cpu_bar, self.cpu_val = _bar_row()
self.mem_lbl, self.mem_bar, self.mem_val = _bar_row()
self.diskfree_lbl, self.diskfree_val = _pair()
self.disk_lbl, self.disk_val = QLabel(), QLabel()
self.network_lbl, self.network_val = QLabel(), QLabel()
res_lay.addStretch(1)
root.addWidget(self.resource_group)
# ---- Audit Log preview --------------------------------------------------
def _build_audit_section(self, root: QVBoxLayout, action_logs_tab_visible: bool) -> None:
self.audit_group = QGroupBox()
self.audit_group.setObjectName("monSection")
audit_lay = QVBoxLayout(self.audit_group)
self.audit_lbl = QLabel()
self.audit_lbl.setWordWrap(True)
self.audit_lbl.setTextFormat(Qt.RichText)
audit_lay.addWidget(self.audit_lbl)
self.view_all_btn = QPushButton()
self.view_all_btn.setFlat(True)
self.view_all_btn.clicked.connect(lambda: self._on_view_all_action_logs())
audit_lay.addWidget(self.view_all_btn, 0, Qt.AlignRight)
self.audit_group.setVisible(action_logs_tab_visible)
root.addWidget(self.audit_group)
# ---- budget --------------------------------------------------------------
def _apply_budget(self) -> None:
"""Persist the spin box's value as the new budget — starts a fresh
remaining-balance window (spend before now is no longer counted)."""
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
self.ctx.save()
self._refresh_budget()
def _refresh_budget(self) -> None:
from ....core import model_pricing as mp
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
status = ut.budget_status(self.ctx.config)
if status is None:
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
self.budget_card.budget_spin.setValue(0.0)
return
amount_disp = mp.convert(status["amount_usd"], "USD",
pricing.get("currency", "USD"), self.ctx.config)
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
pct = int(round(status["pct_used"] * 100))
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
if not self.budget_card.budget_spin.hasFocus():
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
# ---- resource usage --------------------------------------------------------
def _refresh_resource_usage(self) -> None:
try:
import psutil
except ImportError:
self._set_resource_na()
return
try:
own = psutil.Process()
own_cpu = own.cpu_percent(interval=None)
own_mem = own.memory_info().rss
except Exception:
own, own_cpu, own_mem = None, 0.0, 0
self.cpu_bar.setValue(int(min(own_cpu, 100)))
self.cpu_val.setText(f"{own_cpu:.0f}%")
try:
total_mem = psutil.virtual_memory().total
mem_pct = int(own_mem * 100 / total_mem) if total_mem else 0
except Exception:
mem_pct = 0
self.mem_bar.setValue(min(mem_pct, 100))
try:
self.mem_val.setText(f"{fmt_bytes(own_mem)}/{fmt_bytes(total_mem)}")
except Exception: # noqa: BLE001
self.mem_val.setText(fmt_bytes(own_mem))
try:
free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free
self.diskfree_val.setText(tr("monitoring.overview_disk_free", size=fmt_bytes(free)))
except Exception: # noqa: BLE001
self.diskfree_val.setText(tr("monitoring.na"))
now = time.monotonic()
try:
io = own.io_counters() if own is not None else None
disk_bytes = (io.read_bytes + io.write_bytes) if io is not None else None
except Exception:
disk_bytes = None
try:
net = psutil.net_io_counters()
net_bytes = net.bytes_sent + net.bytes_recv
except Exception:
net_bytes = None
prev = self._last_io_sample
self._last_io_sample = (now, disk_bytes, net_bytes)
na = tr("monitoring.na")
if prev and disk_bytes is not None and prev[1] is not None and now > prev[0]:
rate = max(0.0, (disk_bytes - prev[1]) / (now - prev[0]))
self.disk_val.setText(f"{fmt_bytes(rate)}/s")
else:
self.disk_val.setText(na)
if prev and net_bytes is not None and prev[2] is not None and now > prev[0]:
rate = max(0.0, (net_bytes - prev[2]) / (now - prev[0]))
self.network_val.setText(f"{fmt_bytes(rate)}/s")
else:
self.network_val.setText(na)
def _set_resource_na(self) -> None:
na = tr("monitoring.na")
self.cpu_bar.setValue(0)
self.cpu_val.setText(na)
self.mem_bar.setValue(0)
self.mem_val.setText(na)
self.disk_val.setText(na)
self.network_val.setText(na)
# ---- usage cards -----------------------------------------------------------
def _activity_line(self, event: dict) -> str:
ok = event.get("ok", True)
if ok:
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
elif event.get("kind") == "security_block":
mark = f"<span style='color:{DOT_AMBER};'>!</span>"
else:
mark = f"<span style='color:{DOT_RED};'>✗</span>"
name = event.get("name", "") or event.get("kind", "")
rel = relative_time(event.get("ts", ""))
muted = current_palette().text_muted
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
return f"{mark} {name}{suffix}"
def _refresh_usage_cards(self) -> None:
from ....core import model_pricing as mp
mp.sync_to_usage(self.ctx.config) # cost total comes straight from the price table
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
events = ut.load_events()
s = ut.summarize(events)
costs = ut.cost_usd_events(events, pricing)
self.usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]))
self.usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "")
self.usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
ut.format_cost(costs["in"], pricing))
self.usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
ut.format_cost(costs["out"], pricing))
self.usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
ut.format_cost(costs["cache"], pricing))
self.usage_cost.set(
f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}',
ut.format_cost(sum(costs.values()), pricing, digits=2))
self._refresh_budget()
# ---- public API used by the container ---------------------------------
def refresh(self, events: List[dict]) -> None:
"""Full refresh — resource usage + usage cards + sandbox/permissions
+ recent activity + audit preview. ``events`` is the already-loaded
(local-or-shared) audit log, shared with the event-table tabs so the
decision of which source to read from is made exactly once per
refresh tick."""
self._refresh_resource_usage()
self._refresh_usage_cards()
self.sandbox_card.refresh()
recent = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
if recent:
self.activity_lbl.setText("<br>".join(self._activity_line(e) for e in recent[:6]))
self.audit_lbl.setText("<br>".join(
f"{e.get('ts', '')} — {e.get('name', '')}" for e in recent[:4]))
else:
self.activity_lbl.setText(tr("monitoring.overview_no_activity"))
self.audit_lbl.setText(tr("monitoring.overview_no_activity"))
def retranslate(self) -> None:
self.usage_group.setTitle(tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
self.activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
self.resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
self.cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
self.mem_lbl.setText(tr("monitoring.overview_res_mem"))
self.diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
self.disk_lbl.setText(tr("monitoring.overview_res_disk"))
self.network_lbl.setText(tr("monitoring.overview_res_network"))
self.pricing_panel.retranslate()
self.sandbox_card.retranslate()
self.audit_group.setTitle(tr("monitoring.overview_audit_title").upper())
self.view_all_btn.setText(tr("monitoring.overview_view_all"))
@@ -0,0 +1,182 @@
"""Model Pricing panel — the editable price-table card on the Overview page
(currency picker, import/export/add/auto-link/delete, and the table itself).
Extracted from ``ui/monitoring_tab.py``'s pricing-table construction and
``_reload_pricing_table``/``_import_pricing``/``_export_pricing``/
``_add_pricing_row``/``_autolink_pricing``/``_delete_pricing_row``.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtWidgets import (
QComboBox, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout,
)
from ....core import usage_tracker as ut
from ....i18n import tr
from ....ui.icons import icon
class PricingPanel(QGroupBox):
def __init__(self, ctx, on_status_message: Callable[[str], None]):
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
self._worker = None
self.setObjectName("monSection")
pg = QVBoxLayout(self)
phdr = QHBoxLayout()
self.ccy_lbl = QLabel()
self.ccy_lbl.setObjectName("hint")
self.ccy = QComboBox()
for cur in ut.SUPPORTED_CURRENCIES:
self.ccy.addItem(cur, cur)
pidx = self.ccy.findData((self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
self.ccy.setCurrentIndex(max(0, pidx))
self.ccy.currentIndexChanged.connect(self._reload_table)
phdr.addWidget(self.ccy_lbl)
phdr.addWidget(self.ccy)
phdr.addStretch(1)
self.import_btn = QPushButton()
self.import_btn.setIcon(icon("download"))
self.import_btn.clicked.connect(self._import_pricing)
self.export_btn = QPushButton()
self.export_btn.setIcon(icon("upload"))
self.export_btn.clicked.connect(self._export_pricing)
self.add_btn = QPushButton()
self.add_btn.setIcon(icon("plus"))
self.add_btn.clicked.connect(self._add_pricing_row)
self.link_btn = QPushButton()
self.link_btn.setIcon(icon("refresh"))
self.link_btn.clicked.connect(self._autolink_pricing)
self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash"))
self.del_btn.clicked.connect(self._delete_pricing_row)
for b in (self.import_btn, self.export_btn, self.add_btn, self.link_btn, self.del_btn):
phdr.addWidget(b)
pg.addLayout(phdr)
self.table = QTableWidget(0, 5)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
pg.addWidget(self.table, 1)
self._reload_table()
def retranslate(self) -> None:
self.setTitle(tr("monitoring.pricing_title").upper())
self.ccy_lbl.setText(tr("monitoring.pricing_currency"))
self.import_btn.setText(tr("monitoring.pricing_import"))
self.export_btn.setText(tr("monitoring.pricing_export"))
self.add_btn.setText(tr("monitoring.pricing_add"))
self.link_btn.setText(tr("monitoring.pricing_autolink"))
self.del_btn.setText(tr("monitoring.pricing_delete"))
self.table.setHorizontalHeaderLabels([
tr("monitoring.pricing_col_model"), tr("monitoring.pricing_col_context"),
tr("monitoring.pricing_col_maxout"), tr("monitoring.pricing_col_input"),
tr("monitoring.pricing_col_output")])
def _reload_table(self, *_a) -> None:
from ....core import model_pricing as mp
to_ccy = self.ccy.currentData() or "USD"
entries = mp.list_entries(self.ctx.config)
self.table.setRowCount(len(entries))
for r, e in enumerate(entries):
in_v = mp.convert(e.get("input_price", 0), e.get("input_ccy", "USD"), to_ccy, self.ctx.config)
out_v = mp.convert(e.get("output_price", 0), e.get("output_ccy", "USD"), to_ccy, self.ctx.config)
vals = [e.get("model", ""), e.get("context_length", ""), e.get("max_output", ""),
f"{mp.format_price(in_v, to_ccy)} / {e.get('input_unit', '')}",
f"{mp.format_price(out_v, to_ccy)} / {e.get('output_unit', '')}"]
for c, v in enumerate(vals):
self.table.setItem(r, c, QTableWidgetItem(str(v)))
def _import_pricing(self) -> None:
from PySide6.QtWidgets import QFileDialog, QMessageBox
from ....core import model_pricing as mp
path, _ = QFileDialog.getOpenFileName(
self, tr("monitoring.pricing_import"), "", "Pricing (*.xlsx *.csv)")
if not path:
return
default_ccy = self.ccy.currentData() or "USD"
try:
imported = mp.import_table(path, default_ccy=default_ccy)
except ValueError as exc:
QMessageBox.warning(self, tr("monitoring.pricing_title"), str(exc))
return
merged = {e["model"]: e for e in mp.list_entries(self.ctx.config)}
for e in imported:
merged[e["model"]] = e
mp.save_entries(self.ctx.config, list(merged.values()))
self.ctx.save()
self._reload_table()
self._on_status_message(tr("monitoring.pricing_imported", n=len(imported)))
def _export_pricing(self) -> None:
from PySide6.QtWidgets import QFileDialog
from ....core import model_pricing as mp
path, _ = QFileDialog.getSaveFileName(
self, tr("monitoring.pricing_export"), "model_pricing_template.xlsx", "Excel (*.xlsx)")
if not path:
return
mp.export_template(path)
self._on_status_message(tr("monitoring.pricing_exported"))
def _add_pricing_row(self) -> None:
from PySide6.QtWidgets import QInputDialog
from ....core import model_pricing as mp
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt"))
name = (name or "").strip()
if not ok or not name:
return
ccy = self.ccy.currentData() or "USD"
mp.add_entry(self.ctx.config, mp.entry_from_row(
[name, "", "", "0", "Million tokens", "0", "Million tokens"], default_ccy=ccy))
self.ctx.save()
self._reload_table()
def _autolink_pricing(self) -> None:
from ....core import model_pricing as mp
from ....core.worker import AgentWorker
if self._worker is not None:
return
self.link_btn.setEnabled(False)
ctx = self.ctx
ccy = self.ccy.currentData() or "USD"
def job(_w):
return {"entries": mp.auto_link(ctx, default_ccy=ccy)}
def done(r):
self._worker = None
self.link_btn.setEnabled(True)
self.ctx.save()
self._reload_table()
self._on_status_message(tr("monitoring.pricing_linked", n=len(r.get("entries", []))))
def failed(_e):
self._worker = None
self.link_btn.setEnabled(True)
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._worker = w
w.start()
def _delete_pricing_row(self) -> None:
from ....core import model_pricing as mp
row = self.table.currentRow()
entries = mp.list_entries(self.ctx.config)
if 0 <= row < len(entries):
del entries[row]
mp.save_entries(self.ctx.config, entries)
self.ctx.save()
self._reload_table()
+135
View File
@@ -0,0 +1,135 @@
"""Sandbox Details card — the current sandbox id/status/uptime/resource
limits/network state, with a collapsible fold that ALSO nests the
Permissions card inside it (see ``security_settings_tab.PermissionsCard``),
exactly matching the pre-refactor ``ui/monitoring_tab.py`` layout: Sandbox
and Permissions answer the same question ("what is the agent allowed to
touch?"), so they share one fold rather than being two independent
top-level sections. This card is embedded inside ``overview_tab.OverviewTab``
at the same position the original ``QGroupBox`` occupied — no new top-level
tab is added, so the visible UI is unchanged.
"""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from ....i18n import tr
from ..shared.badges import apply_badge
from ..shared.layout_helpers import kv_row
from ..shared.open_settings import open_settings_and_notify
from .security_settings_tab import PermissionsCard
class SandboxDetailsCard(QGroupBox):
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
self.setObjectName("monSection")
sbx_lay = QVBoxLayout(self)
self.summary_lbl = QLabel()
self.summary_lbl.setWordWrap(True)
sbx_lay.addWidget(self.summary_lbl)
self.more_btn = QPushButton()
self.more_btn.setObjectName("co4eSectionAction")
self.more_btn.setFlat(True)
self.more_btn.setCheckable(True)
self.more_btn.setCursor(Qt.PointingHandCursor)
sbx_lay.addWidget(self.more_btn, 0, Qt.AlignLeft)
self._detail = QWidget()
self._detail.setVisible(False)
self.more_btn.toggled.connect(self._detail.setVisible)
self.more_btn.toggled.connect(self._sync_more_label)
sbx_lay.addWidget(self._detail)
detail_lay = QVBoxLayout(self._detail)
detail_lay.setContentsMargins(0, 4, 0, 0)
self.id_lbl, self.id_val = kv_row(detail_lay)
self.status_lbl, self.status_val = kv_row(detail_lay)
self.status_val.setObjectName("badgeSuccess")
self.created_lbl, self.created_val = kv_row(detail_lay)
self.uptime_lbl, self.uptime_val = kv_row(detail_lay)
limits_row = QHBoxLayout()
self.limits_lbl = QLabel()
self.limits_lbl.setObjectName("hint")
self.limits_lbl.setWordWrap(True)
self.edit_btn = QPushButton()
self.edit_btn.setFlat(True)
self.edit_btn.clicked.connect(self._open_settings)
limits_row.addWidget(self.limits_lbl, 1)
limits_row.addWidget(self.edit_btn)
detail_lay.addLayout(limits_row)
self.net_lbl, self.net_val = kv_row(detail_lay)
# The Permissions card is nested inside THIS fold, not a sibling
# section — matches the original layout exactly.
self.permissions_card = PermissionsCard(ctx, on_settings_changed)
detail_lay.addWidget(self.permissions_card)
def _open_settings(self) -> None:
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def _sync_more_label(self, *_a) -> None:
"""Label the fold with what it will do next."""
open_ = self.more_btn.isChecked()
self.more_btn.setText(("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
def retranslate(self) -> None:
self.setTitle(tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
self.id_lbl.setText(tr("monitoring.overview_sandbox_id"))
self.status_lbl.setText(tr("monitoring.overview_status"))
self.created_lbl.setText(tr("monitoring.overview_created"))
self.uptime_lbl.setText(tr("monitoring.overview_uptime"))
self.edit_btn.setText(tr("monitoring.overview_edit"))
self.net_lbl.setText(tr("monitoring.overview_network_label"))
self.permissions_card.retranslate()
self._sync_more_label()
def refresh(self) -> None:
sec = self._ctx.config.agent_security
net_blocked = bool(sec.get("block_network"))
self.id_val.setText(f"sbx_{os.getpid():x}")
self.status_val.setText(tr("monitoring.overview_status_running"))
self.created_val.setText(datetime.fromtimestamp(self._ctx.started_at).strftime("%H:%M:%S"))
uptime_s = max(0, int(time.time() - self._ctx.started_at))
h, rem = divmod(uptime_s, 3600)
m, s = divmod(rem, 60)
self.uptime_val.setText(f"{h}h {m}m {s}s" if h else f"{m}m {s}s")
limit_parts = []
if sec.get("resource_limit_cpu_percent"):
limit_parts.append(f"CPU {sec['resource_limit_cpu_percent']}%")
if sec.get("resource_limit_memory_mb"):
limit_parts.append(f"MEM {sec['resource_limit_memory_mb']}MB")
if sec.get("resource_limit_disk_mb"):
limit_parts.append(f"DISK {sec['resource_limit_disk_mb']}MB")
limits_text = ", ".join(limit_parts) if limit_parts else tr("monitoring.na")
self.limits_lbl.setText(tr("monitoring.overview_resource_limits") + ": " + limits_text)
self.net_val.setText(
tr("monitoring.overview_network_disabled") if net_blocked
else tr("monitoring.overview_network_enabled"))
apply_badge(self.net_val, "badgeWarn" if net_blocked else "badgeSuccess")
# The one line the wireframe shows; the detail above stays a fold away.
self.summary_lbl.setText(" · ".join([
f'{tr("monitoring.overview_perm_fs")}: {tr("monitoring.overview_perm_fs_value")}',
f'{tr("monitoring.overview_perm_network")}: '
f'{tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")}',
f'{tr("monitoring.overview_perm_process")}: {tr("monitoring.overview_perm_process_value")}',
f'{tr("monitoring.overview_resource_limits")}: {limits_text}',
]))
self._sync_more_label()
self.permissions_card.refresh()
@@ -0,0 +1,47 @@
"""Security Events tab — the audit log filtered to ``kind="security_block"``.
Extracted from ``ui/monitoring_tab.py``'s security-events wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class SecurityEventsTab(QWidget):
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
# Security events are always ok=False, so this table trades the
# tick/cross column for a tinted Action column (see EventTable).
self.table = EventTable(show_result=False)
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.security_events_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
self.table.set_events(events)
def retranslate(self) -> None:
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,59 @@
"""Permissions card — "what is the agent allowed to touch?", displayed
nested inside the Sandbox Details card's expandable fold (see
``sandbox_tab.SandboxDetailsCard``), exactly as in the pre-refactor
``ui/monitoring_tab.py`` (``self._sbx_detail.layout().addWidget(self.ov_permissions_group)``).
Editing still opens the same Settings dialog as the Sandbox card's own
"Edit" button — this card only DISPLAYS ``ctx.config.agent_security``, it
does not host its own settings-editing UI.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QPushButton, QVBoxLayout
from ....i18n import tr
from ..shared.badges import apply_badge
from ..shared.layout_helpers import kv_row
from ..shared.open_settings import open_settings_and_notify
class PermissionsCard(QGroupBox):
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
self.setObjectName("monSection")
perm_lay = QVBoxLayout(self)
self.fs_lbl, self.fs_val = kv_row(perm_lay)
self.network_lbl, self.network_val = kv_row(perm_lay)
self.process_lbl, self.process_val = kv_row(perm_lay)
self.env_lbl, self.env_val = kv_row(perm_lay)
self.edit_btn = QPushButton()
self.edit_btn.setFlat(True)
self.edit_btn.clicked.connect(self._open_settings)
perm_lay.addWidget(self.edit_btn, 0, Qt.AlignLeft)
def _open_settings(self) -> None:
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def retranslate(self) -> None:
self.setTitle(tr("monitoring.overview_permissions_title").upper())
self.fs_lbl.setText(tr("monitoring.overview_perm_fs"))
self.fs_val.setText(tr("monitoring.overview_perm_fs_value"))
self.network_lbl.setText(tr("monitoring.overview_perm_network"))
self.process_lbl.setText(tr("monitoring.overview_perm_process"))
self.process_val.setText(tr("monitoring.overview_perm_process_value"))
self.env_lbl.setText(tr("monitoring.overview_perm_env"))
self.env_val.setText(tr("monitoring.overview_perm_env_value"))
self.edit_btn.setText(tr("monitoring.overview_edit"))
def refresh(self) -> None:
net_blocked = bool(self._ctx.config.agent_security.get("block_network"))
self.network_val.setText(
tr("monitoring.overview_perm_network_blocked") if net_blocked
else tr("monitoring.overview_perm_network_allowed"))
apply_badge(self.network_val, "badgeWarn" if net_blocked else "badgeSuccess")
@@ -0,0 +1,73 @@
"""Mục Chung trong Cài đặt — R08-T07.
Ngôn ngữ, giao diện, khay hệ thống, và dòng gợi ý cuối trang.
Bản trước khi tách dựng mục này thành một ``QFormLayout`` rời, gắn vào layout
gốc, rồi ở đoạn lắp ráp lại gỡ ra để nhét vào hộp riêng — kèm một widget cao
0 pixel làm mốc cuộn. Vòng vo đó chỉ tồn tại vì mục này không phải group box
như bốn mục kia. Gói thành widget là hết: nó tự là một trang, không cần gỡ ra
gắn vào, không cần mốc giả.
"""
from __future__ import annotations
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
from ...i18n import LANGUAGES, tr
from ...ui.widgets import SegmentedControl, ToggleSwitch
class GeneralSettingsWidget(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
data = ctx.config.data
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
form = QFormLayout()
self.language_combo = SegmentedControl()
for key, label in LANGUAGES.items():
self.language_combo.addItem(label, key)
_select(self.language_combo, ctx.config.language)
form.addRow(tr("settings.language"), self.language_combo)
# Giao diện cũng có trên hàng tài khoản ở thanh bên (một cú bấm để lật
# nhanh); đây là cùng một giá trị, nhưng có tên và có giải thích, cho
# người đi tìm nó trong Cài đặt.
self.theme_combo = SegmentedControl()
for key in ("system", "dark", "light"):
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
_select(self.theme_combo, getattr(ctx.config, "theme", "system"))
form.addRow(tr("settings.theme"), self.theme_combo)
tray = data.get("tray", {})
self.tray_chk = ToggleSwitch(tr("settings.tray_keep"))
self.tray_chk.setChecked(bool(tray.get("minimize_on_close", True)))
form.addRow("", self.tray_chk)
self.notify_chk = ToggleSwitch(tr("settings.tray_notify"))
self.notify_chk.setChecked(bool(tray.get("notify_on_done", True)))
form.addRow("", self.notify_chk)
outer.addLayout(form)
note = QLabel(tr("settings.tip"))
note.setObjectName("hint")
note.setWordWrap(True) # không thì đúng một dòng này quyết định bề ngang dialog
outer.addWidget(note)
outer.addStretch(1)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
data["language"] = self.language_combo.currentData()
# MainWindow._open_settings áp lại giao diện sau khi dialog đóng, nên
# ghi giá trị ở đây là đủ để nó có hiệu lực.
data["theme"] = self.theme_combo.currentData()
tray = data.setdefault("tray", {})
tray["minimize_on_close"] = self.tray_chk.isChecked()
tray["notify_on_done"] = self.notify_chk.isChecked()
def _select(combo, value: str) -> None:
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
@@ -0,0 +1,98 @@
"""Mục Tham số trong Cài đặt — R08-T07.
Bóc từ ``ui/settings_dialog.py``. Ba nhóm con: đính kèm, cấu trúc/GraphRAG,
và giới hạn tài nguyên sandbox.
Lưu ý khi đọc: nhóm thứ ba **hiện** ở đây nhưng **lưu** vào ``agent_security``
chứ không phải một khoá riêng — nó vốn nằm ở mục Bảo mật sandbox rồi được dời
sang đây cho gần các con số khác. Chỗ hiện và chỗ lưu khác nhau, đừng gộp.
"""
from __future__ import annotations
from PySide6.QtWidgets import QFormLayout, QGroupBox, QLabel, QSpinBox
from ...i18n import tr
class ParameterSettingsWidget(QGroupBox):
def __init__(self, ctx, parent=None):
super().__init__(tr("settings.group.parameter"), parent)
data = ctx.config.data
sec = ctx.config.agent_security
form = QFormLayout(self)
def tieu_de(key: str) -> None:
lbl = QLabel(tr(key))
lbl.setStyleSheet("font-weight:600; margin-top:6px;")
form.addRow(lbl)
# --- đính kèm ---
att = data.get("attachments", {})
tieu_de("settings.group.attachments")
self.attach_files = QSpinBox()
self.attach_files.setRange(1, 50)
self.attach_files.setSuffix(tr("settings.max_files_suffix"))
self.attach_files.setValue(max(1, int(att.get("max_files", 20))))
self.attach_files.setToolTip(tr("settings.max_files_tooltip"))
self.attach_tokens = QSpinBox()
self.attach_tokens.setRange(1, 1000)
self.attach_tokens.setSingleStep(5)
self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix"))
# Lưu theo token, hiện theo nghìn token.
self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000))
self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip"))
form.addRow(tr("settings.max_files"), self.attach_files)
form.addRow(tr("settings.max_per_file"), self.attach_tokens)
# --- cấu trúc / GraphRAG ---
st = data.get("structure", {})
tieu_de("settings.group.structure")
self.struct_nodes = QSpinBox()
self.struct_nodes.setRange(0, 100000)
self.struct_nodes.setSpecialValueText(tr("settings.unlimited"))
self.struct_nodes.setSuffix(tr("settings.nodes_suffix"))
self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500))))
self.struct_nodes.setToolTip(tr("settings.nodes_tooltip"))
self.struct_edges = QSpinBox()
self.struct_edges.setRange(0, 200000)
self.struct_edges.setSpecialValueText(tr("settings.unlimited"))
self.struct_edges.setSuffix(tr("settings.edges_suffix"))
self.struct_edges.setValue(max(0, int(st.get("max_edges", 500))))
self.struct_edges.setToolTip(tr("settings.edges_tooltip"))
form.addRow(tr("settings.max_nodes"), self.struct_nodes)
form.addRow(tr("settings.max_edges"), self.struct_edges)
# --- giới hạn sandbox (lưu vào agent_security) ---
tieu_de("settings.group.sandbox_limits")
self.sandbox_cpu = _gioi_han(" %", int(sec.get("resource_limit_cpu_percent", 0) or 0),
hi=100_000)
form.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu)
self.sandbox_memory = _gioi_han(" MB", int(sec.get("resource_limit_memory_mb", 2048) or 2048))
form.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory)
self.sandbox_disk = _gioi_han(" MB", int(sec.get("resource_limit_disk_mb", 2048) or 2048))
form.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
att = data.setdefault("attachments", {})
att["max_tokens"] = self.attach_tokens.value() * 1000
att["max_files"] = self.attach_files.value()
st = data.setdefault("structure", {})
st["max_nodes"] = self.struct_nodes.value()
st["max_edges"] = self.struct_edges.value()
def apply_limits_to(self, sec: dict) -> None:
"""Ba con số này thuộc ``agent_security``, không thuộc ``structure``."""
sec["resource_limit_cpu_percent"] = self.sandbox_cpu.value()
sec["resource_limit_memory_mb"] = self.sandbox_memory.value()
sec["resource_limit_disk_mb"] = self.sandbox_disk.value()
def _gioi_han(suffix: str, value: int, hi: int = 1_000_000) -> QSpinBox:
box = QSpinBox()
box.setRange(0, hi)
box.setSuffix(suffix)
box.setSpecialValueText(tr("settings.sandbox_unlimited")) # 0 = không giới hạn
box.setValue(value)
return box
@@ -0,0 +1,212 @@
"""Mục AI Provider trong Cài đặt — R08-T07.
Chọn nhà cung cấp, base URL, API key, model — kèm hai nút Tải model và Test
kết nối chạy ở luồng nền.
Điểm cần biết khi sửa: widget giữ **bản nháp cho từng provider**
(``_staging``). Người dùng đổi sang provider khác rồi quay lại thì thấy đúng
những gì mình vừa gõ, dù chưa bấm Lưu. Nếu đọc thẳng từ config thay vì từ bản
nháp là mất phần đang gõ dở.
"""
from __future__ import annotations
from typing import Dict
from PySide6.QtWidgets import (
QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QSizePolicy, QWidget,
)
from ...config import PROVIDER_LABELS
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.icons import icon
class ProviderSettingsWidget(QGroupBox):
def __init__(self, ctx, parent=None):
super().__init__(tr("settings.group.provider"), parent)
self.ctx = ctx
data = ctx.config.data
self._workers = []
self._staging: Dict[str, dict] = {
key: dict(conf) for key, conf in data["providers"].items()
}
self.provider_combo = QComboBox()
for key, label in PROVIDER_LABELS.items():
self.provider_combo.addItem(label, key)
_select(self.provider_combo, ctx.config.active_provider)
self._current_key = self.provider_combo.currentData()
conf = self._staging.get(self._current_key, {})
self.prov_base = QLineEdit(conf.get("base_url", ""))
self.prov_key = QLineEdit(conf.get("api_key", ""))
self.prov_key.setEchoMode(QLineEdit.Password)
self.prov_model = _model_combo(conf.get("model", ""))
self.prov_status = QLabel("")
self.prov_status.setObjectName("hint")
self.prov_status.setWordWrap(True)
form = QFormLayout(self)
form.addRow(tr("settings.active_provider"), self.provider_combo)
form.addRow(tr("settings.base_url"), self.prov_base)
form.addRow(tr("settings.api_key"), self.prov_key)
form.addRow(tr("settings.model"), self._hang_model())
form.addRow("", self.prov_status)
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
data["active_provider"] = self.provider_combo.currentData()
self._stash()
for key, staged in self._staging.items():
data["providers"].setdefault(key, {}).update({
"base_url": staged.get("base_url", ""),
"api_key": staged.get("api_key", ""),
"model": staged.get("model", ""),
})
# ---- bản nháp từng provider -----------------------------------------
def _stash(self) -> None:
self._staging.setdefault(self._current_key, {}).update({
"base_url": self.prov_base.text().strip(),
"api_key": self.prov_key.text(),
"model": self.prov_model.currentText().strip(),
})
def _on_provider_changed(self) -> None:
self._stash()
self._current_key = self.provider_combo.currentData()
conf = self._staging.get(self._current_key, {})
self.prov_base.setText(conf.get("base_url", ""))
self.prov_key.setText(conf.get("api_key", ""))
self.prov_model.clear()
if conf.get("model"):
self.prov_model.addItem(conf["model"])
self.prov_model.setCurrentText(conf["model"])
else:
self.prov_model.setCurrentText("")
self.prov_status.setText("")
def _conf_hien_tai(self, provider: str) -> dict:
if provider == self._current_key:
return {"base_url": self.prov_base.text().strip(),
"api_key": self.prov_key.text(),
"model": self.prov_model.currentText().strip()}
conf = self._staging.get(provider, {})
return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""),
"model": conf.get("model", "")}
# ---- hàng model + hai nút -------------------------------------------
def _hang_model(self) -> QWidget:
row = QWidget()
lay = QHBoxLayout(row)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.prov_model, 1)
btn = QPushButton(tr("settings.load"))
btn.setIcon(icon("download"))
btn.setToolTip(tr("settings.load_tooltip"))
btn.clicked.connect(lambda: self._load_models(self.provider_combo.currentData()))
lay.addWidget(btn)
test_btn = QPushButton(tr("settings.test_connection"))
test_btn.setIcon(icon("flask"))
test_btn.setToolTip(tr("settings.test_connection_tooltip"))
test_btn.clicked.connect(lambda: self._test_connection(self.provider_combo.currentData()))
lay.addWidget(test_btn)
# Hai nút giữ kích thước tự nhiên, combo là thứ phải nhường. Không có
# dòng này thì bề rộng tối thiểu của hàng bằng combo cộng cả hai nút,
# không co lại được, và dialog sinh ra thanh cuộn ngang.
for b in (btn, test_btn):
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
return row
# ---- việc chạy nền ---------------------------------------------------
def _load_models(self, provider: str) -> None:
conf = self._conf_hien_tai(provider)
combo, status = self.prov_model, self.prov_status
def job(worker):
from ...providers import build_provider
prov = build_provider(provider, conf)
return {"models": prov.list_models(), "error": getattr(prov, "last_error", "")}
def done(result):
models = result.get("models") or []
current = combo.currentText().strip()
combo.clear()
if current:
combo.addItem(current)
for m in models:
if m != current:
combo.addItem(m)
combo.setCurrentText(current)
if models:
status.setText(tr("settings.loaded_models", n=len(models),
provider=PROVIDER_LABELS.get(provider, provider)))
else:
status.setText(tr("settings.load_models_error",
err=result.get("error", "")
or tr("settings.load_models_error_unknown")))
self._chay_nen(job, done,
lambda e: status.setText(tr("settings.load_failed", err=e)),
tr("settings.loading_models"))
def _test_connection(self, provider: str) -> None:
conf = self._conf_hien_tai(provider)
status = self.prov_status
def job(worker):
from ...providers import build_provider
ok, message = build_provider(provider, conf).test_connection()
return {"ok": ok, "message": message}
def done(result):
status.setText(result.get("message", ""))
status.setStyleSheet("color: #090;" if result.get("ok") else "color: #c00;")
def failed(e):
status.setText(str(e))
status.setStyleSheet("color: #c00;")
self._chay_nen(job, done, failed, tr("settings.testing_connection"))
def _chay_nen(self, job, done, failed, dang_lam: str) -> None:
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
# Giữ tham chiếu: worker bị thu gom giữa chừng là luồng chết lặng lẽ.
self._workers.append(w)
self.prov_status.setText(dang_lam)
w.start()
def _model_combo(value: str) -> QComboBox:
combo = QComboBox()
combo.setEditable(True)
# Mặc định combo rộng bằng mục dài nhất; id model thì dài, nên hàng này
# tràn ra ngoài dialog và đẻ ra thanh cuộn ngang (tệ hơn ở màn 125%/150%).
# Cho nó co lại, phần bung ra để popup lo.
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
combo.setMinimumContentsLength(8)
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
if value:
combo.addItem(value)
combo.setCurrentText(value)
return combo
def _select(combo: QComboBox, value: str) -> None:
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
@@ -0,0 +1,119 @@
"""Mục Auto Model Routing trong Cài đặt — R08-T07.
Bóc từ ``ui/settings_dialog.py`` (khối dòng 254-309 của bản trước khi tách).
Widget tự dựng control, tự nạp giá trị, tự ghi trả về dict cấu hình. Dialog
chỉ còn việc đặt nó vào chỗ và gọi ``apply_to`` lúc lưu.
"""
from __future__ import annotations
from PySide6.QtWidgets import (
QComboBox, QFormLayout, QGroupBox, QLabel, QLineEdit, QPushButton, QSpinBox,
)
from ...i18n import tr
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo.
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"))
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
("latency", "routing.policy_latency"),
("balanced", "routing.policy_balanced"))
class RoutingSettingsWidget(QGroupBox):
def __init__(self, ctx, parent=None):
super().__init__(tr("routing.settings_group"), parent)
self.ctx = ctx
routing = ctx.config.routing
form = QFormLayout(self)
self.mode = QComboBox()
for value, key in MODE_KEYS:
self.mode.addItem(tr(key), value)
_select(self.mode, routing.get("switch_mode", "off"))
form.addRow(tr("routing.settings_mode"), self.mode)
self.policy = QComboBox()
for value, key in POLICY_KEYS:
self.policy.addItem(tr(key), value)
_select(self.policy, routing.get("policy", "balanced"))
form.addRow(tr("routing.settings_policy"), self.policy)
# Lưu dạng phân lẻ (0..1) nhưng hiện dạng phần trăm.
self.min_gain = QSpinBox()
self.min_gain.setRange(0, 100)
self.min_gain.setSuffix(" %")
self.min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100)))
form.addRow(tr("routing.settings_min_gain"), self.min_gain)
self.timeout = QSpinBox()
self.timeout.setRange(5, 600)
self.timeout.setSuffix(" s")
self.timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60))
form.addRow(tr("routing.settings_timeout"), self.timeout)
self.interval = QSpinBox()
self.interval.setRange(0, 720)
self.interval.setSpecialValueText(tr("routing.mode_off")) # 0 = tắt
self.interval.setSuffix(" h")
self.interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0))
form.addRow(tr("routing.settings_interval"), self.interval)
self.concurrency = QSpinBox()
self.concurrency.setRange(1, 16)
self.concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2))
form.addRow(tr("routing.settings_concurrency"), self.concurrency)
self.judge = QLineEdit(routing.get("judge_model", ""))
form.addRow(tr("routing.settings_judge"), self.judge)
self.reassess_btn = QPushButton(tr("routing.settings_reassess_now"))
self.reassess_btn.clicked.connect(self._reassess_now)
form.addRow("", self.reassess_btn)
hint = QLabel(tr("routing.settings_hint"))
hint.setObjectName("hint")
hint.setWordWrap(True)
form.addRow(hint)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
r = data.setdefault("routing", {})
r["switch_mode"] = self.mode.currentData()
r["policy"] = self.policy.currentData()
r["min_score_gain"] = self.min_gain.value() / 100.0
r["confirm_timeout_sec"] = self.timeout.value()
r["reassess_interval_hours"] = self.interval.value()
r["per_provider_concurrency"] = self.concurrency.value()
r["judge_model"] = self.judge.text().strip()
# ---- đánh giá lại ngay ----------------------------------------------
def _reassess_now(self) -> None:
"""Chạy đánh giá lại model ở nền."""
try:
service = self.ctx.routing()
if service.is_reassessing():
return
self.reassess_btn.setEnabled(False)
self.reassess_btn.setText(tr("routing.reassessing"))
def _done(result) -> None:
self.reassess_btn.setEnabled(True)
self.reassess_btn.setText(
tr("routing.reassess_done", count=len(result or {})))
service.reassess_background(on_done=_done)
except Exception: # noqa: BLE001 — bấm đánh giá lại không được làm sập Cài đặt
self.reassess_btn.setEnabled(True)
self.reassess_btn.setText(tr("routing.settings_reassess_now"))
def _select(combo: QComboBox, value: str) -> None:
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
+42
View File
@@ -0,0 +1,42 @@
"""Composition Root — R08-T10.
Một chỗ duy nhất quyết định app chạy bằng những mảnh nào. Trước đây quyết định
đó nằm rải trong ``app.py::run``, lẫn với việc dựng cửa sổ; tách ra để đổi một
mảnh (ví dụ thay kho bí mật) không phải đụng vào mã giao diện.
Đây cũng là chỗ hoàn tất R02: từ đây app chạy bằng :class:`JsonConfigRepository`
chứ không còn ``config.py::AppConfig``. Hai thứ đổi thật sự:
* ghi cấu hình qua ``AtomicJsonFile`` — mất điện giữa lúc lưu không làm hỏng file
* API key nằm trong kho bí mật của hệ điều hành, không nằm trong ``config.json``
Máy không có kho bí mật (Linux headless, CI, hoặc keyring hỏng) vẫn chạy bình
thường: repository nhận ``secrets=None`` và đọc khoá thẳng từ file như cũ. Thà
để khoá trong file còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao.
"""
from __future__ import annotations
from pathlib import Path
from ...infrastructure.config.json_config_repository import JsonConfigRepository
from ...infrastructure.secrets.keyring_adapter import KeyringAdapter
from ...state import AppContext
def build_secret_store():
"""Kho bí mật của hệ điều hành, hoặc None nếu máy này không có.
``KeyringAdapter`` không bao giờ ném lỗi — nó tự báo ``available``. Trả về
None thay vì một adapter chết để chỗ gọi khỏi phải đoán.
"""
store = KeyringAdapter()
return store if store.available else None
def build_config(path: Path | None = None) -> JsonConfigRepository:
return JsonConfigRepository.load(path, secrets=build_secret_store())
def build_context(path: Path | None = None) -> AppContext:
"""Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker."""
return AppContext(build_config(path))
+26
View File
@@ -0,0 +1,26 @@
"""Tên gọi và biểu tượng của ứng dụng — R08-T10.
Tách riêng vì cả cửa sổ chính lẫn thanh trên cùng đều cần, mà để ở một trong
hai thì file kia phải import ngược lại — vòng import.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtGui import QIcon
#: branding.py nằm sâu 2 cấp nên phải trỏ ngược lên gốc gói.
ASSETS = Path(__file__).resolve().parents[2] / "assets"
def app_icon() -> QIcon:
"""The buffalo app icon, used everywhere (window title bar, Windows taskbar and
the tray). The multi-size ``.ico`` is loaded FIRST so Windows has the right
pixmap for the taskbar; the high-res ``.png`` is added so the icon stays crisp
at large sizes. This keeps the taskbar icon identical to the app's icon."""
icon = QIcon()
for name in ("icon.ico", "icon.png"):
path = ASSETS / name
if path.exists():
icon.addFile(str(path))
return icon
+110
View File
@@ -0,0 +1,110 @@
"""Vòng đời cửa sổ chính — R08-T10.
Bóc từ ``app.py::MainWindow``. Hai việc, đều không phải việc của giao diện:
1. **Canh cửa sổ theo màn hình đang đứng.** Người dùng có hai màn khác độ phân
giải và khác tỉ lệ phóng; kéo cửa sổ sang màn kia là vùng làm việc đổi. Đây
là số học thuần, không đụng widget nào ngoài chính cửa sổ.
2. **Tắt cho sạch.** Dừng bộ lập lịch, dừng mọi lượt chạy còn dở, ngắt tiến
trình MCP. Thiếu một bước là để lại tiến trình con chạy mồ côi sau khi
người dùng đã thoát.
Các hàm ``closeEvent``/``moveEvent``/``resizeEvent`` vẫn phải nằm ở lớp cửa sổ
— Qt gọi thẳng vào đó — nhưng phần quyết định thì ở đây.
"""
from __future__ import annotations
from PySide6.QtGui import QGuiApplication
#: Cửa sổ chiếm bao nhiêu phần màn hình khi mở lần đầu.
SCREEN_SHARE_W, SCREEN_SHARE_H = 0.80, 0.85
#: Chừa mép để cửa sổ không đụng thanh tác vụ.
MARGIN = 60
#: Kích thước tối thiểu mong muốn — vẫn phải nhỏ hơn màn hình thật.
MIN_W, MIN_H = 820, 520
class LifecycleCoordinator:
def __init__(self, window):
self.window = window
self._last_screen = None
# ---- canh theo màn hình ----------------------------------------------
def fit_to_screen(self, want_w: int, want_h: int) -> None:
w = self.window
screen = w.screen() or QGuiApplication.primaryScreen()
avail = screen.availableGeometry() if screen else None
if avail is None:
w.resize(want_w, want_h)
return
# Lấy một phần màn hình: không bao giờ nhỏ hơn kích thước yêu cầu, cũng
# không bao giờ lớn hơn thứ màn hình hiển thị nổi.
width = min(max(want_w, int(avail.width() * SCREEN_SHARE_W)),
avail.width() - MARGIN)
height = min(max(want_h, int(avail.height() * SCREEN_SHARE_H)),
avail.height() - MARGIN)
self._apply_minimum(avail)
w.resize(max(width, 1), max(height, 1))
frame = w.frameGeometry()
frame.moveCenter(avail.center())
w.move(frame.topLeft())
def screen_maybe_changed(self) -> bool:
"""Gọi khi cửa sổ bị di chuyển. Trả True nếu đúng là đã đổi màn hình.
Trả về bool để chỗ gọi biết có cần xếp lại mấy thứ nổi hay không —
kéo cửa sổ trong cùng một màn thì không cần làm gì cả.
"""
w = self.window
screen = w.screen()
if screen is self._last_screen:
return False
self._last_screen = screen
avail = screen.availableGeometry() if screen else None
if avail is not None:
self._apply_minimum(avail)
return True
def _apply_minimum(self, avail) -> None:
# Kích thước tối thiểu không bao giờ được vượt quá thứ màn hình hiển
# thị nổi — nếu không thì cửa sổ không thu nhỏ vừa màn được nữa.
self.window.setMinimumSize(min(MIN_W, avail.width() - MARGIN),
min(MIN_H, avail.height() - MARGIN))
# ---- đóng và tắt ------------------------------------------------------
def should_keep_running(self) -> bool:
"""Đóng cửa sổ có nghĩa là chạy nền tiếp, hay là thoát hẳn?
Chạy nền tiếp chỉ khi có khay hệ thống để quay lại — không có khay mà
vẫn ẩn đi thì người dùng mất luôn đường vào app.
"""
w = self.window
if w.tray is None or w._really_quit:
return False
return bool(w.ctx.config.data.get("tray", {}).get("minimize_on_close", True))
def shutdown(self) -> None:
"""Dừng mọi thứ đang chạy. Thứ tự có ý nghĩa: bộ lập lịch trước, để nó
không kịp khởi động thêm việc mới trong lúc ta đang dừng việc cũ."""
w = self.window
w.task_scheduler.stop() # dừng luôn các task đã lên lịch
if getattr(w, "routing_scheduler", None) is not None:
w.routing_scheduler.stop()
for tab in (w.cowork,):
for worker in tab.active_workers():
if worker.isRunning():
worker.request_stop()
worker.wait(1500)
if hasattr(w.structure, "stop_cmem_ui"):
w.structure.stop_cmem_ui()
# Không bao giờ để lại tiến trình MCP đã kết nối chạy mồ côi.
w.ctx.stop_mcp_connections()
+362
View File
@@ -0,0 +1,362 @@
"""Cửa sổ chính — R08-T10.
Bóc nguyên khối ra khỏi ``app.py``. ``app.py`` giờ chỉ còn điểm vào của chương
trình: dựng QApplication, gọi Composition Root, mở cửa sổ.
Vì sao tách: ``app.py`` là nơi mọi thứ đổ về — nó vừa là điểm vào, vừa giữ cửa
sổ, vừa giữ thanh điều hướng, vừa giữ thanh trên cùng. Ai sửa bất cứ mảng nào
cũng phải mở đúng một file 1.293 dòng, và ba người sửa ba mảng khác nhau thì
đụng nhau ở cùng một chỗ.
"""
from __future__ import annotations
"""
pages (Dashboard / Schedule / Workspace / Cowork / Structure) and top bar."""
import sys
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QStackedWidget, QVBoxLayout, QWidget
from ... import DISPLAY_NAME, __version__
from ...i18n import on_language_changed, tr
from .branding import app_icon
from .lifecycle_coordinator import LifecycleCoordinator
from .page_registry import PageRegistryMixin
from .rail_project import RailProjectMixin
from .session_events import SessionEventsMixin
from .toast import Toast
from .top_bar import TopBarMixin
from .nav_rail import NavRailMixin
from .rail_metrics import _NAV_MIN_WIDTH
from .tray_manager import TrayManager
from ...state import AppContext
from ...core.task_scheduler import TaskScheduler
from ...ui.cowork_tab import CoworkTab
from ...ui.sidebar import HistorySidebar
from ...ui.structure_graph_view import StructureGraphView
from ...ui.workspace_tab import WorkspaceTab
# Nav rail (sidebar navigation) widths — expanded shows icon+label, collapsed
# shows icon-only (still fully clickable, just narrower).
# The splitter between rail and content draws a drag handle. It only means
# something if the rail can actually take a width from it, so the expanded rail
# is a range rather than one number; long project and thread names in RECENTS
# are the reason someone would widen it.
#
# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter
# of a 1440 screen and more than a quarter of a 1280 one, where it left the
# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on
# every monitor.
# Where a rail row starts, and how much air sits between its icon and its
# label. The tree rows get these from the style; anything laid out by hand
# beside them has to use the same two numbers or it will not line up.
class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
PageRegistryMixin, SessionEventsMixin, QMainWindow):
#: Biểu tượng khay, hoặc None nếu máy không có khay. Vẫn giữ tên cũ vì
#: còn vài chỗ đọc thẳng self.tray; bản thân việc dựng/ẩn/thông báo đã
#: chuyển sang self._tray (TrayManager).
tray = property(lambda self: self._tray.icon)
# Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page).
_ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3
def __init__(self, ctx: AppContext, user_name: str = ""):
super().__init__()
self.ctx = ctx
self._user_name = user_name
self._really_quit = False
self._life = LifecycleCoordinator(self)
# Khay hệ thống: presentation/shell/tray_manager.py (R08-T10).
self._tray = TrayManager(self, icon=app_icon, tooltip=DISPLAY_NAME, tr=tr)
self._nav_collapsed = False # icon-only nav rail toggle (Task: collapsible nav)
self._history_collapsed = False # remembers History's own collapse-to-strip state
self.setWindowTitle(f"{DISPLAY_NAME} v{__version__}")
self.setWindowIcon(app_icon())
# Fit to the available screen so the window never opens larger than the
# monitor (auto-fit). Keep a modest minimum that still fits small laptops.
self._fit_to_screen(1180, 760)
self.sidebar = HistorySidebar(ctx)
# Task scheduler ENGINE runs in the background whether or not its Kanban
# UI (built lazily) is on screen — scheduled tasks must fire regardless.
self.task_scheduler = TaskScheduler(ctx, parent=self)
# Desktop notification when a scheduled task finishes; also refresh
# History — a cowork/code task run saves itself as a new session there.
self.task_scheduler.task_finished.connect(self._on_scheduled_task_done)
# NOTE: task_started fires BEFORE the worker thread even begins, so its
# session doesn't exist on disk yet — refreshing History here would
# find nothing. history_ready fires once the session is actually
# saved (right as the run starts, then again after each turn), which
# is what really makes a Running task's session show up live.
self.task_scheduler.history_ready.connect(lambda _tid: self._refresh_history())
# Cowork chat + GraphRAG view are embedded as sub-tabs INSIDE the
# Workspace screen (per selected project). GraphRAG's heavy
# QtWebEngine is still built lazily on first display
# (StructureGraphView._ensure_web).
self.cowork = CoworkTab(ctx)
self.structure = StructureGraphView(ctx)
self.structure.status_message.connect(self.statusBar().showMessage)
self.cowork.output_changed.connect(self.structure.schedule_rescan)
self.cowork.status_message.connect(self.statusBar().showMessage)
# Refresh History (list + running markers + current highlight) whenever a
# conversation is created/updated or a turn finishes.
self.cowork.turn_finished.connect(lambda *_: self._refresh_history())
self.cowork.history_changed.connect(self._refresh_history)
self.cowork.turn_finished.connect(
lambda result: self._notify_task(self.cowork, "cowork", result))
# Workspace screen — the app HOME: project management + the per-project
# Cowork / GraphRAG sub-tabs and History.
self.workspace = WorkspaceTab(ctx, cowork=self.cowork, structure=self.structure,
sidebar=self.sidebar)
self.workspace.status_message.connect(self.statusBar().showMessage)
self.workspace.projects_changed.connect(self._on_projects_changed)
self.workspace.open_chat.connect(lambda *_: self._refresh_history())
self.workspace.new_chat.connect(lambda *_: self._refresh_history())
# Dashboard + Schedule pages are built lazily on first visit (lazy page
# creation — keeps startup light); None until then.
self.dashboard = None
self.schedule = None
self.monitoring = None
# --- right side: top bar + pages (nav rail drives the stack) ---
right = QWidget()
right.setObjectName("contentArea")
rlay = QVBoxLayout(right)
rlay.setContentsMargins(10, 10, 10, 10)
rlay.setSpacing(10)
rlay.addWidget(self._build_topbar())
self.pages = QStackedWidget()
# (i18n key, icon, builder-or-None, eager-widget-or-None) — page index == list index
self._nav_defs = [
("app.tab.dashboard", "dashboard", self._build_dashboard, None),
("app.tab.schedule", "schedule", self._build_schedule, None),
("app.tab.workspace", "workspaces", None, self.workspace),
("app.tab.monitoring", "monitoring", self._build_monitoring, None),
]
self._page_widgets = [] # page index → widget (placeholder until lazily built)
self._built = []
for _key, _icon_name, _builder, widget in self._nav_defs:
page = widget if widget is not None else QWidget()
self.pages.addWidget(page)
self._page_widgets.append(page)
self._built.append(widget is not None)
self._build_nav_rail(right, rlay)
# Landing stays Workspace ▸ Project, exactly as before. Go through _goto
# so the page is actually shown — selecting the row alone only moves the
# highlight (its signals are blocked to avoid rebuild loops).
self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab())
self.toast = Toast(self) # top-left "task done" popup
# Floating in-app Help assistant — a robot icon pinned bottom-right on
# every screen; expands into a small help-only chat (see
# ui/help_agent_widget.py). Managed in Monitoring → Agents Admin.
from ...ui.help_agent_widget import HelpAgentWidget
self.help_agent = HelpAgentWidget(ctx, self, user_name=self._user_name)
self.help_agent.status_message.connect(self.statusBar().showMessage)
self.statusBar().showMessage(tr("app.status.ready"))
# Author credit, pinned to the bottom-right corner. A permanent status-bar
# widget sits at the right end and is never cleared by showMessage (which
# writes on the left).
self._credit = QLabel(tr("app.credit"))
self._credit.setObjectName("faint")
self._credit.setStyleSheet("padding: 0 10px;")
self.statusBar().addPermanentWidget(self._credit)
self._restore_sessions()
self._tray.setup()
# Start the task scheduler last, once the whole window exists — it
# catches up any overdue tasks right away (first tick runs inline).
self.task_scheduler.start()
# Auto Model Routing: periodic reassess + pending-switch expiry. Runs
# background probes only when genuinely due (never a burst at launch).
try:
from ...core.routing.scheduler import RoutingScheduler
self.routing_scheduler = RoutingScheduler(self.ctx, self.ctx.routing(), parent=self)
self.routing_scheduler.start()
except Exception: # noqa: BLE001 — routing must never block app startup
self.routing_scheduler = None
on_language_changed(self._retranslate)
def resizeEvent(self, event): # noqa: N802 - Qt override
super().resizeEvent(event)
# The rail's ceiling is a share of the window, so it moves with the
# window. Computed once at construction it was read off a not-yet-sized
# window and stuck at 162px on every monitor.
if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed:
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
# Keep the floating Help assistant pinned to the bottom-right corner.
if getattr(self, "help_agent", None) is not None:
self.help_agent.reposition()
def showEvent(self, event): # noqa: N802 - Qt override
super().showEvent(event)
if getattr(self, "help_agent", None) is not None:
self._update_dock_guard()
self.help_agent.reposition()
self.help_agent.raise_()
# Build GraphRAG's browser view and first graph once the window is up
# and idle, so clicking GraphRAG does not sit on an empty view while
# both happen. 3s is after the first paint and any startup refresh.
if not getattr(self, "_graph_prewarmed", False):
self._graph_prewarmed = True
QTimer.singleShot(3000, self._prewarm_graph)
def _prewarm_graph(self) -> None:
view = getattr(self, "structure", None)
if view is None or not hasattr(view, "prewarm"):
return
try:
view.prewarm()
except Exception: # noqa: BLE001 — a warm-up must never break the app
pass
# ---- i18n ----------------------------------------------------------
def _retranslate(self) -> None:
"""Re-apply the current language to this window's own static chrome
(tabs are the only long-lived text here; the tabs/dialogs retranslate
themselves)."""
self._apply_nav_labels()
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
self._nav_toggle_btn.setToolTip(
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
self._credit.setText(tr("app.credit"))
if hasattr(self, "provider_lbl"):
self.provider_lbl.setText(tr("app.provider"))
if hasattr(self, "settings_btn"):
self.settings_btn.setText(tr("app.settings"))
if hasattr(self, "theme_btn"):
self.theme_btn.setToolTip(tr("settings.theme"))
for value, act in self._theme_actions.items():
act.setText(tr(f"settings.theme_{value}"))
if hasattr(self, "logo_lbl"):
self.logo_lbl.setText(tr("app.logo"))
if getattr(self, "help_agent", None) is not None:
self.help_agent.retranslate()
self._tray.retranslate()
# ---- system tray (run in background when the window is closed) ---
# ---- lazy page building -------------------------------------------
# Monitoring KEEPS its own tab strip: its eight sub-views live in the
# page, not in the rail. Workspace is the one that hides its strip,
# because the rail lists its sub-views directly.
# ---- flat nav rail -------------------------------------------------
def _show_window(self) -> None:
self.showNormal()
self.raise_()
self.activateWindow()
def _quit_app(self) -> None:
self._really_quit = True
self.close()
# ---- top bar -----------------------------------------------------
_BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg")
_BRAND_LOGO_HEIGHT = 22
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
# ---- handlers ----------------------------------------------------
def _update_dock_guard(self) -> None:
"""Keep the floating assistant clear of a screen's own bottom bar.
Only Cowork has one (the composer). Everywhere else the dock sits in
the corner as before.
"""
dock = getattr(self, "help_agent", None)
if dock is None:
return
guard = 0
on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE
and self.workspace.current_subtab() == self.workspace._cowork_tab_idx)
if on_cowork:
comp = getattr(self.cowork, "composer", None)
if comp is not None and not comp.isHidden():
# Measured from the composer's TOP edge in window coordinates:
# its own height misses the extra row of controls laid out under
# it, which left the dot still overlapping by ~25px.
origin = comp.mapTo(self, comp.rect().topLeft())
# ...but only lift the dot if the composer is actually beneath
# it. The composer stops at the chat column's right edge, well
# short of the dot, so lifting it there raised the dot 156px for
# nothing — on Cowork alone it sat off the corner every other
# screen keeps it in.
dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x()
dock_right = dock_left + dock.width()
if dock_right > origin.x() and dock_left < origin.x() + comp.width():
guard = max(0, self.height() - origin.y() + 8)
dock.set_bottom_guard(guard)
# ---- sizing ------------------------------------------------------
# Share of the available screen the window takes when it has room to. Fixed
# pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K
# panel. `want_*` stays the floor so a small screen behaves as before.
# Canh cửa sổ và tắt sạch: presentation/shell/lifecycle_coordinator.py
def _fit_to_screen(self, want_w: int, want_h: int) -> None:
self._life.fit_to_screen(want_w, want_h)
def _on_screen_maybe_changed(self) -> None:
if not self._life.screen_maybe_changed():
return
if getattr(self, "help_agent", None) is not None:
self._update_dock_guard()
self.help_agent.reposition()
def moveEvent(self, event): # noqa: N802 - Qt override
super().moveEvent(event)
# Dragged to another monitor: its work area (and scaling) may differ, so
# the floating assistant re-pins and the panes re-decide if they fit.
self._on_screen_maybe_changed()
# ---- lifecycle ---------------------------------------------------
def closeEvent(self, event) -> None: # noqa: N802
if self._life.should_keep_running():
# Chạy nền tiếp: task vẫn chạy và vẫn tự lưu.
event.ignore()
self.hide()
self._tray.show_message(DISPLAY_NAME, tr("app.tray.running_body"), msec=4000)
return
# Real quit: stop every running turn (a tab may have several), then close.
self._life.shutdown()
self._tray.hide()
super().closeEvent(event)
+385
View File
@@ -0,0 +1,385 @@
"""Thanh điều hướng bên trái — R08-T10.
Bóc từ ``MainWindow``: 18 phương thức dựng và điều khiển thanh rail, cộng danh
sách RECENTS, bộ chọn project, và việc thu gọn về dải icon 54px.
Đây là **mixin**, không phải widget rời — nói thẳng để khỏi hiểu nhầm. Cả 18
phương thức đọc/ghi state của cửa sổ (``self._page_widgets``, ``self.workspace``,
``self.splitter``…). Biến thành đối tượng cộng tác thì phải viết lại từng chỗ
``self.X`` thành ``self.window.X``, tức sửa gần 300 dòng chỉ để đổi cách gọi —
rủi ro cao mà không đổi hành vi. Mixin cho được thứ đang cần: mỗi mảng nằm ở
một file, ai sửa rail thì mở file rail.
Chuyển thành widget thật khi thanh rail cần dùng lại ở cửa sổ khác — hiện chưa.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QScrollArea, QSizePolicy, QSplitter, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
from ...i18n import tr
from .rail_metrics import _NAV_COLLAPSED_WIDTH, _NAV_EXPANDED_WIDTH, _NAV_MAX_CEILING, _NAV_MAX_SHARE, _NAV_MIN_WIDTH, _NavItemDelegate
from ...ui.widgets import tidy_popup
class NavRailMixin:
"""18 phương thức thanh rail. Trộn vào MainWindow."""
def _build_nav_rail(self, right, rlay) -> None:
"""Dựng toàn bộ thanh rail và ghép với vùng nội dung.
Bóc khỏi ``MainWindow.__init__`` — 162 dòng dựng rail nằm lẫn giữa
phần dựng trang và phần khởi động scheduler, nên đọc ``__init__`` là
phải lội qua cả rail mới tới được thứ mình cần.
"""
# Left nav rail — ONE FLAT LIST, no accordion. Every screen the user
# works in is one click away: the Workspace sub-views are listed
# directly instead of hiding behind an expandable parent. The two
# occasional admin destinations sit in a second, bottom-pinned list.
#
# Monitoring is the exception that keeps its sub-views OUT of the rail:
# it has eight, which would double the rail's length for screens opened
# once a week. Its own tab strip is left visible instead (it was hidden
# while the rail carried its children), so all eight stay reachable.
self.nav = self._new_nav_tree("navrail")
self.nav_bottom = self._new_nav_tree("navrailBottom")
self._nav_building = False # guards the rebuild → select → rebuild loop
self.workspace.hide_tab_bar()
self._rebuild_nav()
self.workspace.subtabs_changed.connect(self._rebuild_nav)
for tree in (self.nav, self.nav_bottom):
tree.currentItemChanged.connect(
lambda cur, _prev, t=tree: self._on_nav_current(t, cur))
rlay.addWidget(self.pages, 1)
# Nav rail wrapper: a small toggle button ABOVE the page list so the
# whole rail can collapse to icon-only (still fully clickable). Same
# collapse/expand chevron iconography as every other collapsible panel.
from ...ui.icons import collapse_left_icon, collapse_right_icon
from ...ui.icons import icon as _icon
self._collapse_left_icon = collapse_left_icon
self._collapse_right_icon = collapse_right_icon
self._nav_wrap = QWidget()
self._nav_wrap.setObjectName("navWrap")
self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
nvl = QVBoxLayout(self._nav_wrap)
nvl.setContentsMargins(0, 0, 0, 0)
nvl.setSpacing(0)
# Small, left-aligned "MENU" button (icon + label) instead of a
# full-width centered icon — sits flush with the rail's left edge,
# matching how the nav items themselves align their icon+label.
self._nav_toggle_btn = QPushButton(tr("app.nav.menu_label"))
self._nav_toggle_btn.setIcon(collapse_left_icon())
self._nav_toggle_btn.setObjectName("navMenuBtn")
self._nav_toggle_btn.setFlat(True)
self._nav_toggle_btn.setCursor(Qt.PointingHandCursor)
self._nav_toggle_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._nav_toggle_btn.clicked.connect(self._toggle_nav)
# Zero left margin: the button's own QSS padding (6px) then lines its
# 16px icon up with the nav items' icons below (1px list frame + item
# padding) — same indent level, same icon size as e.g. Dashboard.
toggle_row = QHBoxLayout()
toggle_row.setContentsMargins(0, 8, 10, 8)
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
toggle_row.addStretch(1)
nvl.addLayout(toggle_row)
# Primary action at the top of the rail, with the project it will land
# in named right above it. Before, starting a chat in another project
# meant leaving Cowork → Project tab → click a row → come back.
self.nav_project = QComboBox()
self.nav_project.setObjectName("navProjectPick")
self.nav_project.setToolTip(tr("app.nav.project_pick"))
self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick)
tidy_popup(self.nav_project)
self.nav_new_chat = QPushButton(tr("cowork.new_chat"))
self.nav_new_chat.setObjectName("navNewChatBtn")
self.nav_new_chat.setIcon(_icon("plus"))
self.nav_new_chat.setCursor(Qt.PointingHandCursor)
self.nav_new_chat.clicked.connect(self._on_rail_new_chat)
# At 54px the picker cannot show a name, but dropping it altogether left
# the collapsed rail with no way to change project at all. This stands in
# for it: same list, same handler, just the folder icon and a tooltip.
self.nav_project_btn = QToolButton()
self.nav_project_btn.setObjectName("navProjectPickMini")
self.nav_project_btn.setIcon(_icon("folder"))
self.nav_project_btn.setCursor(Qt.PointingHandCursor)
self.nav_project_btn.setPopupMode(QToolButton.InstantPopup)
self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.nav_project_btn.setMenu(QMenu(self.nav_project_btn))
self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu)
self.nav_project_btn.setVisible(False)
head = QVBoxLayout()
head.setContentsMargins(6, 0, 6, 6)
head.setSpacing(6)
head.addWidget(self.nav_project)
head.addWidget(self.nav_project_btn)
head.addWidget(self.nav_new_chat)
nvl.addLayout(head)
self.workspace.project_selected.connect(self._sync_rail_project)
self.workspace.projects_changed.connect(self._sync_rail_project)
self._syncing_rail_project = False
self._sync_rail_project()
# The destinations and RECENTS scroll together; the bottom group, the
# Settings button and the account row stay pinned below them.
#
# Without this the rail simply ran out of room on a short window (a
# 1280×720 laptop leaves ~570px here): nav and the bottom group have
# fixed heights, so the squeeze fell entirely on RECENTS, and once that
# hit zero the layout drew the "GẦN ĐÂY" heading straight over the last
# nav row.
self._nav_scroll = QScrollArea()
self._nav_scroll.setObjectName("navScroll")
self._nav_scroll.setWidgetResizable(True)
self._nav_scroll.setFrameShape(QScrollArea.NoFrame)
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_body = QWidget()
sv = QVBoxLayout(scroll_body)
sv.setContentsMargins(0, 0, 0, 0)
sv.setSpacing(0)
sv.addWidget(self.nav, 0)
# RECENTS — the threads of the project named in the picker above, right
# where Claude puts them. A shortcut only: the full History panel (search,
# filters, pin, bulk delete, context menu) stays exactly where it is, and
# "all projects…" at the end of this list opens it.
self.nav_recents_hdr = QLabel(tr("app.nav.recents"))
self.nav_recents_hdr.setObjectName("navSectionHdr")
sv.addWidget(self.nav_recents_hdr)
self.nav_recents = self._new_nav_tree("navRecents")
self.nav_recents.itemClicked.connect(self._on_rail_recent)
sv.addWidget(self.nav_recents, 1)
# Collapsing hides RECENTS, and with it the only item carrying a stretch
# factor. A box layout with nothing left to expand centres what remains,
# so the destinations dropped ~300px down the rail — "thu gọn menu lại
# ra giữa". This spacer takes the slack instead, and takes none of it
# while RECENTS is visible (stretch 0 against its 1).
sv.addStretch(0)
self._nav_scroll.setWidget(scroll_body)
nvl.addWidget(self._nav_scroll, 1)
self._build_rail_bottom(nvl)
nvl.addWidget(self._account_row)
self.split = QSplitter(Qt.Horizontal)
self.split.addWidget(self._nav_wrap)
self.split.addWidget(right)
self.split.setStretchFactor(0, 0)
self.split.setStretchFactor(1, 1)
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
self.split.splitterMoved.connect(self._on_split_moved)
self.setCentralWidget(self.split)
def _new_nav_tree(self, name: str) -> QTreeWidget:
"""One flat, single-column list. No indentation and no expand arrows —
every row is a destination, nothing is a container."""
tree = QTreeWidget()
tree.setObjectName(name)
tree.setHeaderHidden(True)
tree.setIndentation(0)
tree.setRootIsDecorated(False)
tree.setUniformRowHeights(True)
# The column follows the viewport instead of the widest label. Left
# to size itself it stayed ~100px wide inside the 54px collapsed
# rail, so a horizontal scrollbar appeared and slid the icons out of
# the position they hold while the rail is open.
from PySide6.QtWidgets import QHeaderView
tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
tree.setItemDelegate(_NavItemDelegate(tree))
return tree
def _nav_rows(self):
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
Workspace contributes all five of its sub-views — including the two the
project gate currently disables — so the rail never changes shape while
the user is looking at it.
"""
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
for label, sub, ic, on in self.workspace.nav_entries()]
rows.append((self.nav, self._ROW_SCHEDULE, None,
tr("app.tab.schedule"), "schedule", True))
rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,
tr("app.tab.dashboard"), "dashboard", True))
rows.append((self.nav_bottom, self._ROW_MONITORING, None,
tr("app.tab.monitoring"), "monitoring", True))
return rows
def _rebuild_nav(self, force: bool = False) -> None:
"""Re-fill both lists from _nav_rows(), keeping the current selection.
Rebuilding changes the current item, which would fire navigation and can
loop back here via subtabs_changed — hence the guard and the blocked
signals.
"""
if self._nav_building:
return
spec = self._nav_rows()
# Rebuilding deletes the QTreeWidgetItems, including the one a signal is
# currently being delivered for. subtabs_changed fires on every visit to
# Workspace, so skip the rebuild unless the rows really differ.
sig = [(label, page, sub, enabled)
for _t, page, sub, label, _ic, enabled in spec]
if not force and sig == getattr(self, "_nav_sig", None):
return
self._nav_sig = sig
self._nav_building = True
try:
from ...ui.icons import icon as _icon
keep = self._current_nav_key()
for tree in (self.nav, self.nav_bottom):
blocked = tree.blockSignals(True)
tree.clear()
tree.blockSignals(blocked)
for tree, page, sub, label, icon_name, enabled in spec:
it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
it.setIcon(0, _icon(icon_name))
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
if not enabled:
# Same gate as before, shown instead of hidden: the row stays
# in place, greyed, and says why it cannot be opened.
it.setDisabled(True)
it.setToolTip(0, tr("app.nav.needs_project"))
elif self._nav_collapsed:
it.setToolTip(0, label)
blocked = tree.blockSignals(True)
tree.addTopLevelItem(it)
tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the
# stretch in between belongs to RECENTS.
for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else 0
tree.setFixedHeight(n * row_h + 8)
if keep:
self._select_nav_row(*keep)
finally:
self._nav_building = False
def _current_nav_key(self):
"""(page, sub) of the highlighted row, or None."""
for tree in (self.nav, self.nav_bottom):
it = tree.currentItem()
if it is not None and it.isSelected():
data = it.data(0, Qt.UserRole) or {}
if "page" in data:
return data["page"], data.get("sub")
return None
def _select_nav_row(self, page: int, sub) -> None:
"""Highlight the row for (page, sub) without triggering navigation.
Called both when the user clicks (to keep the two lists mutually
exclusive) and from _goto, so programmatic navigation moves the
highlight too — it used to stay behind on whatever was clicked last.
"""
for tree in (self.nav, self.nav_bottom):
blocked = tree.blockSignals(True)
match = None
for i in range(tree.topLevelItemCount()):
it = tree.topLevelItem(i)
data = it.data(0, Qt.UserRole) or {}
if data.get("page") == page and (
data.get("sub") == sub or data.get("sub") is None):
match = it
break
if match is not None:
tree.setCurrentItem(match)
else:
tree.setCurrentItem(None)
tree.clearSelection()
tree.blockSignals(blocked)
def _on_nav_current(self, tree: QTreeWidget, item) -> None:
"""A row was picked: clear the other list so only one row looks active."""
if item is None or self._nav_building:
return
data = item.data(0, Qt.UserRole) or {}
other = self.nav_bottom if tree is self.nav else self.nav
blocked = other.blockSignals(True)
other.setCurrentItem(None)
other.clearSelection()
other.blockSignals(blocked)
self._goto(data.get("page", 0), data.get("sub"))
# ---- rail header: project picker + new chat ------------------------
# ---- rail RECENTS --------------------------------------------------
_RAIL_RECENTS = 5
def _apply_nav_labels(self) -> None:
"""Re-label every row for the current language and collapse state
(collapsed = icon only, label moves to the tooltip)."""
# force: collapsing leaves the row spec identical, only the text changes.
self._rebuild_nav(force=True)
self._nav_settings_text.setText(tr("app.settings"))
self._nav_settings_text.setVisible(not self._nav_collapsed)
self._nav_settings_btn.setToolTip(tr("app.settings"))
# Collapsed to 54px there is no room for either control's label; the
# picker would be a stub of a name, so it steps aside entirely and the
# button keeps just its + icon.
self.nav_project.setVisible(not self._nav_collapsed)
self.nav_project_btn.setVisible(self._nav_collapsed)
self._refresh_rail_recents()
# Collapsed to 54px only the theme toggle still fits; the rest of the
# account row would be clipped, so it steps aside (Settings, which opens
# the same values in a dialog, stays reachable as an icon).
self.account_lbl.setVisible(not self._nav_collapsed)
self.language_combo.setVisible(not self._nav_collapsed)
self.provider_combo.setVisible(not self._nav_collapsed)
self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat"))
if self._nav_new_chat_enabled():
self.nav_new_chat.setToolTip(
tr("cowork.new_chat") if self._nav_collapsed else "")
self._sync_rail_project()
def _nav_max_width(self) -> int:
"""The rail's ceiling for THIS window, as a share of it."""
return max(_NAV_MIN_WIDTH,
min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE)))
def _set_nav_width_range(self, lo: int, hi: int) -> None:
"""setFixedWidth would leave the splitter handle inert — visible, and
doing nothing when dragged."""
self._nav_wrap.setMinimumWidth(lo)
self._nav_wrap.setMaximumWidth(hi)
def _on_split_moved(self, _pos: int, _index: int) -> None:
if not self._nav_collapsed:
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
def _toggle_nav(self) -> None:
if not self._nav_collapsed:
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
self._nav_collapsed = not self._nav_collapsed
if self._nav_collapsed:
width = _NAV_COLLAPSED_WIDTH
self._set_nav_width_range(width, width)
else:
width = self._nav_width
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
self._apply_nav_labels()
# Same chevron convention as every other collapsible panel: right-
# pointing (fill-right) means "click to expand", left means "collapse".
self._nav_toggle_btn.setIcon(
self._collapse_right_icon() if self._nav_collapsed else self._collapse_left_icon())
# Collapsed rail is icon-only (54px) — the "MENU" label wouldn't fit
# next to the icon, same rule the nav items themselves follow.
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
self._nav_toggle_btn.setToolTip(
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
# Give/reclaim the width difference to the main content pane.
sizes = self.split.sizes()
if len(sizes) == 2:
diff = sizes[0] - width
sizes[0] = width
sizes[1] = max(1, sizes[1] + diff)
self.split.setSizes(sizes)
+82
View File
@@ -0,0 +1,82 @@
"""Bốn màn chính và cách chuyển giữa chúng — R08-T10.
Dashboard và Lịch chỉ được dựng ở lần mở đầu tiên (dựng lười) — mở app không
phải trả giá cho hai màn có thể cả phiên không ai vào. ``_ensure_page`` là chỗ
duy nhất biết điều đó, nên mọi đường tới một trang đều phải đi qua ``_goto``.
Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from ...i18n import tr
from ...ui.dashboard_tab import DashboardTab
from ...ui.monitoring_tab import MonitoringTab
from ...ui.schedule_task_tab import ScheduleTaskTab
class PageRegistryMixin:
def _page_index(self, widget) -> int:
return self.pages.indexOf(widget)
def _build_dashboard(self):
d = DashboardTab(self.ctx)
d.status_message.connect(self.statusBar().showMessage)
self.dashboard = d
return d
def _build_schedule(self):
s = ScheduleTaskTab(self.ctx, self.task_scheduler)
s.status_message.connect(self.statusBar().showMessage)
self.schedule = s
return s
def _build_monitoring(self):
m = MonitoringTab(self.ctx, cowork=self.cowork, structure=self.structure,
task_scheduler=self.task_scheduler)
m.status_message.connect(self.statusBar().showMessage)
self.monitoring = m
return m
def _ensure_page(self, row: int) -> None:
"""Build a lazy nav page on first visit and swap it in for its placeholder."""
if not (0 <= row < len(self._built)) or self._built[row]:
return
builder = self._nav_defs[row][2]
if builder is None:
return
real = builder()
placeholder = self._page_widgets[row]
self.pages.insertWidget(row, real) # placeholder shifts to row+1
self.pages.removeWidget(placeholder)
placeholder.deleteLater()
self._page_widgets[row] = real
self._built[row] = True
def _page_index(self, widget) -> int:
if widget is self.workspace:
return self._ROW_WORKSPACE
if self.dashboard is not None and widget is self.dashboard:
return self._ROW_DASHBOARD
if self.schedule is not None and widget is self.schedule:
return self._ROW_SCHEDULE
if self.monitoring is not None and widget is self.monitoring:
return self._ROW_MONITORING
return self.pages.indexOf(widget)
def _goto(self, page: int, sub) -> None:
self._ensure_page(page) # build lazy page on first visit
self.pages.setCurrentIndex(page)
if page == self._ROW_WORKSPACE:
self.workspace.refresh() # re-list projects + threads on entry
widget = self._page_widgets[page]
if sub is not None and hasattr(widget, "select_subtab"):
# Enforce the project gate here rather than at each entry point. A
# greyed rail row cannot be clicked, but _goto is also reached from
# RECENTS and from startup restore, and it used to open a sub-tab
# the gate was holding shut — page shown, tab strip still hiding it.
if hasattr(widget, "subtab_available") and not widget.subtab_available(sub):
self.statusBar().showMessage(tr("app.nav.needs_project"), 4000)
else:
widget.select_subtab(sub)
# Move the highlight with the content, however navigation was triggered —
# a programmatic _goto used to leave it on whatever was clicked last.
if not self._nav_building:
self._select_nav_row(page, sub)
self._update_dock_guard()
# Switching pages updates which conversation is "current".
self._refresh_history()
+37
View File
@@ -0,0 +1,37 @@
"""Kích thước và cách vẽ một hàng trên thanh rail — R08-T10.
Chỉ số và cách vẽ, không có hành vi. Tách riêng vì ``theme.py`` cũng phải biết
mấy con số này (nó style ``#navrailBottom`` theo cùng lề), và vì thứ hay phải
tra lại nhất khi chỉnh giao diện là chúng — không nên nằm lẫn trong 400 dòng
dựng widget.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QStyledItemDelegate
# ---- kích thước ---------------------------------------------------------
_NAV_EXPANDED_WIDTH = 150
_NAV_COLLAPSED_WIDTH = 54
_NAV_ROW_INSET = 4
_NAV_ROW_GAP = 6
_NAV_MIN_WIDTH = 132
_NAV_MAX_SHARE = 0.22
_NAV_MAX_CEILING = 360
class _NavItemDelegate(QStyledItemDelegate):
"""Keep a rail row's icon on the left edge, whatever the column is doing.
QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so
a row with no label — every row once the rail collapses to 54px — has its
icon centred inside whatever box the column happens to give it. That box
tracks the column width, which is not stable: stretched to the viewport the
icons land in the middle of the rail, while a column left wider than the
view leaves them at the left. Same code, two different pictures, which is
why a test render disagreed with the running app.
"""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter
+132
View File
@@ -0,0 +1,132 @@
"""Bộ chọn project và danh sách RECENTS trên thanh rail — R08-T10.
Tách khỏi ``nav_rail.py``: thanh rail có hai phần đời sống khác hẳn nhau.
Phần điểm đến (Dashboard, Workspace, Giám sát…) là **tĩnh** — dựng một lần,
đổi khi đổi ngôn ngữ. Phần này thì **động**: đổi mỗi lần người dùng chọn
project khác, mỗi lần một cuộc trò chuyện được tạo hay kết thúc.
Trộn chung một file thì mỗi lần sửa danh sách gần đây lại phải cuộn qua toàn
bộ phần dựng rail. Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
from ...i18n import tr
from ...ui.widgets import tidy_popup
from ...theme import current_palette
class RailProjectMixin:
"""Bộ chọn project + RECENTS. Trộn vào MainWindow."""
def _sync_rail_project(self, *_a) -> None:
"""Mirror the workspace's project list/selection into the rail picker.
One-way on purpose: the project list stays the source of truth, this is
only a second place to see and change it.
"""
if self._syncing_rail_project:
return
self._syncing_rail_project = True
try:
choices = self.workspace.project_choices()
current = self.workspace.selected_project_id()
self.nav_project.clear()
for name, pid in choices:
self.nav_project.addItem(f"📁 {name}", pid)
if not choices:
# No project yet: say so, and say what to do about it, instead of
# leaving an empty box and a button that silently does nothing.
self.nav_project.addItem(tr("app.nav.no_project"), "")
idx = self.nav_project.findData(current)
if idx >= 0:
self.nav_project.setCurrentIndex(idx)
has = bool(choices)
tidy_popup(self.nav_project)
self.nav_project.setEnabled(has)
self.nav_project_btn.setEnabled(has)
self.nav_project_btn.setToolTip(
self.nav_project.currentText().replace("📁 ", "")
if has else tr("app.nav.create_project_first"))
self.nav_new_chat.setEnabled(has)
self.nav_new_chat.setToolTip(
"" if has else tr("app.nav.create_project_first"))
finally:
self._syncing_rail_project = False
def _fill_rail_project_menu(self) -> None:
"""Mirror the picker's items. Choosing one moves the picker, which runs
_on_rail_project_pick — the collapsed rail adds no second code path."""
menu = self.nav_project_btn.menu()
menu.clear()
for i in range(self.nav_project.count()):
act = menu.addAction(self.nav_project.itemText(i))
act.setCheckable(True)
act.setChecked(i == self.nav_project.currentIndex())
act.triggered.connect(
lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row))
def _on_rail_project_pick(self, _idx: int) -> None:
if self._syncing_rail_project:
return
pid = self.nav_project.currentData()
if pid:
self.workspace.choose_project(pid)
def _refresh_rail_recents(self) -> None:
"""Re-fill the rail's recents from the active project's history."""
from ...ui.icons import DOT_BLUE, dot_icon
from ...ui.icons import icon as _icon
tree = self.nav_recents
blocked = tree.blockSignals(True)
tree.clear()
running = self._running_session_ids()
threads = self.workspace.recent_threads(self._RAIL_RECENTS)
for t in threads:
it = QTreeWidgetItem([t["title"]])
it.setToolTip(0, t["title"])
if t["session_id"] in running:
it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History
elif t["pinned"]:
it.setIcon(0, _icon("pin"))
it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]})
tree.addTopLevelItem(it)
if not threads:
it = QTreeWidgetItem([tr("sidebar.empty")])
it.setDisabled(True)
tree.addTopLevelItem(it)
# The way back to everything the rail cannot show — styled as a link
# (italic, accent-colored) so it reads as "go elsewhere", not another row.
more = QTreeWidgetItem([tr("app.nav.all_projects")])
more.setData(0, Qt.UserRole, {"all": True})
more_font = more.font(0)
more_font.setItalic(True)
more.setFont(0, more_font)
more.setForeground(0, QColor(current_palette().accent))
tree.addTopLevelItem(more)
tree.blockSignals(blocked)
self.nav_recents_hdr.setVisible(not self._nav_collapsed)
self.nav_recents.setVisible(not self._nav_collapsed)
def _on_rail_recent(self, item, _col: int = 0) -> None:
data = item.data(0, Qt.UserRole) or {}
if data.get("all"):
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
self.workspace.show_history_pane()
return
path = data.get("path")
if path:
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
self.workspace.open_thread(path, data.get("kind", "cowork"))
def _on_rail_new_chat(self) -> None:
"""Start a new chat, from any screen.
Same call the Cowork toolbar button makes — that button stays exactly
where it was; this is a second entry point, not a replacement.
"""
self._goto(self._ROW_WORKSPACE, None)
self.workspace.start_new_chat()
self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab())
def _nav_new_chat_enabled(self) -> bool:
return bool(self.workspace.project_choices())
+104
View File
@@ -0,0 +1,104 @@
"""Lịch sử hội thoại và thông báo khi task chạy xong — R08-T10.
Gom những gì phản ứng với việc **có chuyện xảy ra ở nơi khác**: một task đã lên
lịch chạy xong, một phiên được lưu, danh sách project đổi.
Điểm dễ sai đã ghi lại trong ``_on_scheduled_task_done``: tín hiệu
``task_started`` bắn TRƯỚC khi luồng chạy bắt đầu, lúc đó phiên chưa có trên
đĩa — làm mới Lịch sử ở đó thì không thấy gì. Phải bám ``history_ready``.
Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from ... import DISPLAY_NAME
from ...i18n import tr
from ...ui.workspace_tab import WorkspaceTab
class SessionEventsMixin:
def _running_session_ids(self):
"""All conversation ids currently running — interactive Cowork/Code
chat tab AgentWorkers, plus Schedule Task runs (their own session,
tracked by the scheduler), so a task's live run gets the same
"running" marker in History an interactive chat gets."""
return set(self.cowork.running_session_ids()) | self.task_scheduler.running_session_ids()
def _refresh_history(self) -> None:
"""Rebuild the History list with the current conversation highlighted and
the running ones marked. Deferred to the next event-loop tick: this is often
triggered (via load_conversation) from inside the sidebar's own item-click
handler, and clearing the tree there would delete the item mid-click."""
from PySide6.QtCore import QTimer
def _do() -> None:
current = self.cowork.session_id
self.sidebar.set_view_state(current, self._running_session_ids())
self.sidebar.refresh()
self._refresh_rail_recents() # the rail shortcut follows the panel
QTimer.singleShot(0, _do)
def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
"""Desktop notification for a finished scheduled task (toast always,
tray balloon when the window isn't focused), then refresh History —
cowork/co4e task runs just saved themselves as new sessions there."""
from ...core.tasks import load_task
task = load_task(task_id) or {}
title = task.get("title", "")
msg = (tr("app.toast.task_done", title=title) if ok
else tr("app.toast.task_failed", title=title))
self.toast.show_message(msg, ok=ok)
if (self.tray is not None
and self.ctx.config.data.get("tray", {}).get("notify_on_done", True)
and not self.isActiveWindow()):
self._tray.show_message(DISPLAY_NAME, msg, error=not ok)
self._refresh_history()
def _notify_task(self, tab, kind: str, result: dict) -> None:
"""Notify when a task finishes/fails (skip if more stages queued)."""
if tab.composer.has_queue():
return # a flow / queue is still running — notify only at the end
name = tr(f"app.tab.{kind}")
err = (result or {}).get("error")
# In-app popup at the top-left (shown whether or not the window is focused).
self.toast.show_message(
tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name), ok=not err)
# System-tray balloon only when the window isn't the active one.
if self.tray is None:
return
if not self.ctx.config.data.get("tray", {}).get("notify_on_done", True):
return
if self.isActiveWindow():
return # user is looking at the window already
err = (result or {}).get("error")
title = tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name)
body = (err if err else (tab._last_assistant_text() or "Task completed."))[:140]
self._tray.show_message(title, body, error=bool(err))
def _restore_sessions(self) -> None:
"""Reopen the last conversation per tab (recover after a crash/abrupt exit)."""
from pathlib import Path
from ...core.history import load_conversation
last = self.ctx.config.data.get("last_session", {})
path = last.get("cowork", "")
if path and Path(path).exists():
try:
self.cowork.load_conversation(load_conversation(path))
# Reflect the restored thread's project in the Workspace home
# (selecting the matching row won't wipe it — the project id
# already matches, so _bind_project starts no new session).
# Skip forcing the Cowork tab open for a project that no
# longer exists (deleted since this session was saved) — that
# would show the Cowork page while the tab strip still says
# "no project selected" (see WorkspaceTab._on_sidebar_open).
pid = self.cowork.project_id
if pid in ("", "default") or self.workspace._select_project_row(pid):
self.workspace._show_cowork_tab()
except Exception:
pass
def _on_projects_changed(self) -> None:
self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
+40
View File
@@ -0,0 +1,40 @@
"""Thông báo nhỏ tự ẩn ở góc trên trái cửa sổ — R08-T10.
Hiện ngay trong app, khác với bong bóng khay hệ thống ở ``tray_manager.py``:
cái này hiện dù cửa sổ có đang được focus hay không, cái kia chỉ hiện khi
người dùng đang nhìn chỗ khác.
"""
from __future__ import annotations
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel
from ...theme import current_palette
class Toast(QLabel):
"""A small auto-hiding notification shown at the window's top-left."""
def __init__(self, parent):
super().__init__(parent)
self.setObjectName("toast")
self.setWordWrap(True)
self.setMaximumWidth(380)
self.setVisible(False)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide)
def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None:
p = current_palette()
bg = p.success_soft if ok else p.danger_soft
fg = p.success if ok else p.danger
self.setStyleSheet(
f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};"
f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}")
self.setText(text)
self.adjustSize()
self.move(14, 14) # top-left of the window
self.raise_()
self.setVisible(True)
self._timer.start(ms)
+234
View File
@@ -0,0 +1,234 @@
"""Thanh trên cùng và hàng tài khoản — R08-T10.
Bóc từ ``MainWindow``: logo, chọn provider, chọn ngôn ngữ, nút đổi giao diện,
và lối mở hộp thoại Cài đặt.
Cùng lý do mixin như ``nav_rail.py``: các phương thức này đọc/ghi state của cửa
sổ. Xem ghi chú ở đầu file đó.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QToolButton, QVBoxLayout, QWidget
from ...config import PROVIDER_LABELS
from ...i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr
from .branding import ASSETS
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
from ...ui.widgets import tidy_popup
from ...theme import set_active_theme, stylesheet
from ...ui.settings_dialog import SettingsDialog
class TopBarMixin:
"""Thanh trên cùng. Trộn vào MainWindow."""
def _build_rail_bottom(self, nvl) -> None:
"""Đáy thanh rail: nhóm ghim dưới, nút Cài đặt, hàng tài khoản.
Nằm ở file thanh trên cùng chứ không phải file rail, vì ba thứ này
đều là "tài khoản và thiết lập" — cùng mối quan tâm với
``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
from ...i18n import tr
from ...ui.icons import icon as _icon
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
# Bottom-pinned group: the places you visit occasionally, kept out of the
# way of the ones you live in. A hairline (styled via #navrailBottom in
# theme.py) separates the two lists.
nvl.addWidget(self.nav_bottom, 0)
# Settings reads as one more row under Dashboard / Giám sát, so its icon
# and label must start exactly where theirs do. Letting QPushButton place
# them does not achieve that: the gap it leaves between icon and text is
# the platform style's, and on macOS it is visibly tighter than the tree
# rows above — a Windows-tuned nudge only moved the mismatch. So the row
# is laid out here, in the same two numbers the tree uses: 4px in, 6px
# between.
self._nav_settings_btn = QPushButton()
self._nav_settings_btn.setObjectName("navSettingsBtn")
self._nav_settings_btn.setFlat(True)
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
self._nav_settings_btn.clicked.connect(self._open_settings)
srow = QHBoxLayout(self._nav_settings_btn)
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
srow.setSpacing(_NAV_ROW_GAP)
self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(16, 16)
self._nav_settings_text = QLabel(tr("app.settings"))
srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text)
srow.addStretch(1)
nvl.addWidget(self._nav_settings_btn)
self._account_row = self._build_account_row()
def _build_topbar(self) -> QWidget:
bar = QWidget()
bar.setObjectName("topbar")
# Styled centrally (see theme._TEMPLATE): flat, with a single hairline
# separating it from the content below — no card box behind it.
h = QHBoxLayout(bar)
h.setContentsMargins(16, 10, 12, 10)
h.setSpacing(10)
# FPT logo slot in front of the brand text: shown only when a logo
# image has been dropped into assets/ (see _brand_logo_pixmap) — the
# brand works text-only until the real artwork is supplied.
self.logo_img = QLabel()
logo_pm = self._brand_logo_pixmap()
if logo_pm is not None:
self.logo_img.setPixmap(logo_pm)
else:
self.logo_img.setVisible(False)
h.addWidget(self.logo_img)
self.logo_lbl = QLabel(tr("app.logo"))
self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE
h.addWidget(self.logo_lbl)
h.addStretch(1)
# Provider / language / theme / Settings used to live here, five controls
# wide across the top of every screen. They are per-account settings, not
# per-screen ones, so they moved to the account row at the foot of the
# rail (_build_account_row) — same widgets, same handlers, new home.
return bar
def _build_account_row(self) -> QWidget:
"""The rail's foot: who you are, and the settings that follow you.
Nothing new is introduced here — these are the exact widgets the top bar
used to hold, moved as-is so every existing signal still lands.
"""
box = QWidget()
box.setObjectName("navAccount")
v = QVBoxLayout(box)
v.setContentsMargins(6, 4, 6, 4)
v.setSpacing(4)
who = QHBoxLayout()
who.setSpacing(4)
self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤")
self.account_lbl.setObjectName("hint")
who.addWidget(self.account_lbl, 1)
self.language_combo = QComboBox()
for key in LANGUAGES:
self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key)
self.language_combo.setItemData(
self.language_combo.count() - 1, LANGUAGES[key], Qt.ToolTipRole)
idx = self.language_combo.findData(get_language())
if idx >= 0:
self.language_combo.setCurrentIndex(idx)
tidy_popup(self.language_combo)
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
who.addWidget(self.language_combo)
self.theme_btn = self._build_theme_button()
who.addWidget(self.theme_btn)
v.addLayout(who)
self.provider_lbl = QLabel(tr("app.provider"))
self.provider_lbl.setObjectName("hint")
self.provider_lbl.setVisible(False) # the combo names itself in the rail
self.provider_combo = QComboBox()
self.provider_combo.setToolTip(tr("app.provider"))
for key, label in PROVIDER_LABELS.items():
self.provider_combo.addItem(label, key)
tidy_popup(self.provider_combo)
idx = self.provider_combo.findData(self.ctx.config.active_provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
v.addWidget(self.provider_lbl)
v.addWidget(self.provider_combo)
return box
def _brand_logo_pixmap(self):
"""The FPT logo scaled to top-bar height, or None while no logo file
exists yet — drop the artwork into src/cowork_local/assets/ under one
of the _BRAND_LOGO_NAMES and it appears on next launch."""
from PySide6.QtGui import QPixmap
for name in self._BRAND_LOGO_NAMES:
path = ASSETS / name
if not path.exists():
continue
pm = QPixmap(str(path))
if pm.isNull():
continue
return pm.scaledToHeight(self._BRAND_LOGO_HEIGHT, Qt.SmoothTransformation)
return None
def _build_theme_button(self) -> QToolButton:
"""A single icon button (System/Dark/Light) replacing the old
Settings-only theme dropdown — one click applies the choice
immediately via the existing _apply_theme(), no dialog round-trip."""
from ...ui.icons import icon as _icon
btn = QToolButton()
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
self._theme_actions = {}
for value, icon_name in self._THEME_ICONS.items():
act = menu.addAction(_icon(icon_name), tr(f"settings.theme_{value}"))
act.triggered.connect(lambda _checked=False, v=value: self._set_theme(v))
self._theme_actions[value] = act
btn.setMenu(menu)
btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
return btn
def _set_theme(self, value: str) -> None:
from ...ui.icons import icon as _icon
self.ctx.config.theme = value
self.ctx.save()
self._apply_theme()
self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor")))
def _on_provider_changed(self, _idx: int) -> None:
self.ctx.config.active_provider = self.provider_combo.currentData()
self.ctx.save()
self.cowork.refresh_header()
# Reload the Cowork tab's Agent (Model) list for the newly selected provider.
self.cowork.refresh_agents()
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
self.statusBar().showMessage(
tr("app.status.using_provider",
label=PROVIDER_LABELS.get(self.ctx.config.active_provider))
)
def _on_language_changed(self, _idx: int) -> None:
lang = self.language_combo.currentData()
if not lang or lang == get_language():
return
self.ctx.config.language = lang
self.ctx.save()
set_language(lang) # notifies every registered persistent widget
def _open_settings(self) -> None:
dlg = SettingsDialog(self.ctx, self)
if dlg.exec():
self._apply_theme()
# Settings can change the theme too — keep the rail's toggle icon
# showing the value that is actually in effect.
from ...ui.icons import icon as _theme_icon
self.theme_btn.setIcon(
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
set_language(self.ctx.config.language) # apply if changed in Settings
# reflect provider/theme/language changes
i = self.provider_combo.findData(self.ctx.config.active_provider)
if i >= 0:
self.provider_combo.setCurrentIndex(i)
li = self.language_combo.findData(get_language())
if li >= 0:
self.language_combo.blockSignals(True)
self.language_combo.setCurrentIndex(li)
self.language_combo.blockSignals(False)
self.cowork.refresh_header()
self.cowork.refresh_agents()
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)
self.cowork.composer.set_max_attachments(max_files)
self.sidebar.refresh()
self.statusBar().showMessage(tr("app.status.settings_saved"))
def _apply_theme(self) -> None:
app = QApplication.instance()
if app:
set_active_theme(self.ctx.config.theme)
app.setStyleSheet(stylesheet(self.ctx.config.theme))
# Re-apply theme styles to chat bubbles so they adapt to the new theme.
self.cowork.apply_theme()
if getattr(self, "help_agent", None) is not None:
self.help_agent.apply_theme() # chat body follows theme (header stays fixed)
+76
View File
@@ -0,0 +1,76 @@
"""Biểu tượng khay hệ thống — R08-T10.
Bóc từ ``app.py::MainWindow``. Giữ biểu tượng khay, menu chuột phải của nó, và
việc bắn thông báo bong bóng.
Vì sao tách: khay là thứ **có thể không tồn tại**. Máy không có khay hệ thống
(một số môi trường Linux, phiên RDP) thì ``isSystemTrayAvailable()`` trả False
và mọi thứ ở đây phải im lặng chấp nhận. Trộn lẫn trong MainWindow thì mỗi chỗ
dùng đều phải tự nhớ kiểm ``if self.tray is not None`` — đã có 6 chỗ như thế.
Gói lại thì chỗ gọi cứ gọi, không có khay thì không có gì xảy ra.
"""
from __future__ import annotations
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu, QSystemTrayIcon
class TrayManager:
"""Khay hệ thống của một cửa sổ. An toàn khi máy không có khay."""
def __init__(self, window, *, icon, tooltip: str, tr):
self.window = window
self._tr = tr
self.icon: QSystemTrayIcon | None = None
self._open_act: QAction | None = None
self._quit_act: QAction | None = None
self._tooltip = tooltip
self._app_icon = icon
# ---- dựng ------------------------------------------------------------
def setup(self) -> None:
"""Dựng biểu tượng khay. Không có khay thì lặng lẽ bỏ qua."""
if not QSystemTrayIcon.isSystemTrayAvailable():
return
w = self.window
self.icon = QSystemTrayIcon(self._app_icon(), w)
self.icon.setToolTip(self._tooltip)
menu = QMenu()
self._open_act = QAction(self._tr("app.tray.open"), w)
self._open_act.triggered.connect(w._show_window)
self._quit_act = QAction(self._tr("app.tray.quit"), w)
self._quit_act.triggered.connect(w._quit_app)
menu.addAction(self._open_act)
menu.addAction(self._quit_act)
self.icon.setContextMenu(menu)
self.icon.activated.connect(
lambda reason: w._show_window() if reason == QSystemTrayIcon.Trigger else None)
self.icon.show()
def retranslate(self) -> None:
if self.icon is not None:
self.icon.setToolTip(self._tooltip)
if self._open_act is not None:
self._open_act.setText(self._tr("app.tray.open"))
self._quit_act.setText(self._tr("app.tray.quit"))
def hide(self) -> None:
if self.icon is not None:
self.icon.hide()
# ---- thông báo -------------------------------------------------------
def show_message(self, title: str, body: str, *, error: bool = False,
msec: int = 5000) -> None:
"""Bắn bong bóng khay. Không có khay, hoặc hệ điều hành từ chối, thì
thôi — một thông báo không hiện được không đáng làm hỏng lượt chạy."""
if self.icon is None:
return
kind = QSystemTrayIcon.Critical if error else QSystemTrayIcon.Information
try:
self.icon.showMessage(title, body, kind, msec)
except Exception: # noqa: BLE001
pass
+75
View File
@@ -0,0 +1,75 @@
"""Task 4b — agent_security <-> agent_security_alert circular dependency is gone.
Before this fix, ``agent_security_alert.py`` imported ``SecurityVerdict`` from
``agent_security.py`` at module level (for the ``notify_admin`` type
annotation), while ``agent_security.py`` deferred-imported
``agent_security_alert.notify_admin`` inside ``enforce_prompt``/
``enforce_command`` — an architectural cycle only avoided at runtime by
pushing that second import inside a function body.
``SecurityVerdict``/``SecurityBlocked`` now live in the dependency-free leaf
module ``agent_security_types.py``. ``agent_security_alert.py`` imports the
type from there instead of from ``agent_security.py``, which lets
``agent_security.py`` import ``agent_security_alert.notify_admin`` at module
top level with no cycle.
"""
from __future__ import annotations
from cowork_local.core import (
agent_security,
agent_security_alert,
agent_security_types,
)
def test_shared_types_live_in_the_leaf_module() -> None:
assert agent_security.SecurityVerdict is agent_security_types.SecurityVerdict
assert agent_security.SecurityBlocked is agent_security_types.SecurityBlocked
assert agent_security_alert.SecurityVerdict is agent_security_types.SecurityVerdict
def test_agent_security_alert_no_longer_imports_agent_security() -> None:
assert "agent_security" not in agent_security_alert.__dict__
def test_notify_admin_imported_at_module_top_level_in_agent_security() -> None:
assert agent_security.notify_admin is agent_security_alert.notify_admin
def test_enforce_command_still_blocks_and_alerts_like_before(monkeypatch) -> None:
class _FakeProvider:
def chat(self, messages, tools=None):
return {"content": '{"allowed": false, "reason": "destructive"}'}
class _Config:
data = {"agent_security": {"enabled": True, "validate_commands": True,
"command_ai_check": True}}
ms365 = {}
@property
def agent_security(self):
return self.data["agent_security"]
notify_calls = []
record_calls = []
monkeypatch.setattr(agent_security, "notify_admin",
lambda config, verdict, detail="": notify_calls.append((verdict, detail)))
from cowork_local.core import audit_log
monkeypatch.setattr(audit_log, "record",
lambda *a, **k: record_calls.append((a, k)))
emitted = []
raised = False
try:
agent_security.enforce_command(
_FakeProvider(), "run_command", {"command": "rm -rf /"}, _Config(),
emit=emitted.append,
)
except agent_security.SecurityBlocked as exc:
raised = True
assert exc.verdict.layer == "command"
assert raised is True
assert notify_calls
assert record_calls
assert emitted and emitted[0]["type"] == "notice"
+95
View File
@@ -0,0 +1,95 @@
"""Task 2 — CanonicalAuditLogger.
Verifies: (1) the infrastructure class itself round-trips events correctly
and mirrors to a shared dir, (2) ``core/audit_log.py``'s wrapper functions
still behave exactly as before (same signatures, same dict schema, same
never-raise guarantee), and (3) old-format raw dicts (as written by the
pre-refactor ``core/audit_log.py``) still load correctly for backward
compatibility.
"""
from __future__ import annotations
import json
from cowork_local.core import audit_log
from cowork_local.infrastructure.telemetry.audit_logger import (
KIND_MCP_CALL,
KIND_SECURITY_BLOCK,
CanonicalAuditEvent,
CanonicalAuditLogger,
)
def test_record_and_load_round_trip(tmp_path) -> None:
logger = CanonicalAuditLogger(tmp_path)
logger.set_identity("alice", "machine-1", role="admin")
logger.record(KIND_SECURITY_BLOCK, "run_command", False, detail="blocked it")
events = logger.load_events()
assert len(events) == 1
e = events[0]
assert e.kind == KIND_SECURITY_BLOCK
assert e.name == "run_command"
assert e.ok is False
assert e.detail == "blocked it"
assert e.account == "alice"
assert e.machine == "machine-1"
assert e.role == "admin"
def test_load_events_filters_by_kind(tmp_path) -> None:
logger = CanonicalAuditLogger(tmp_path)
logger.record(KIND_SECURITY_BLOCK, "a", False)
logger.record(KIND_MCP_CALL, "b", True)
only_mcp = logger.load_events(kind=KIND_MCP_CALL)
assert [e.name for e in only_mcp] == ["b"]
def test_shared_dir_mirroring(tmp_path) -> None:
shared = tmp_path / "shared"
logger = CanonicalAuditLogger(tmp_path / "audit")
logger.set_identity("bob", "machine-2", shared_dir=str(shared))
logger.record(KIND_MCP_CALL, "tool_x", True)
mirrored_files = list((shared / "telemetry" / "audit").glob("machine-2-*.jsonl"))
assert len(mirrored_files) == 1
def test_from_dict_is_tolerant_of_old_partial_rows() -> None:
old_row = {"ts": "2024-01-01T00:00:00", "kind": "tool_call", "name": "x", "ok": True}
event = CanonicalAuditEvent.from_dict(old_row)
assert event.detail == ""
assert event.account == ""
def test_record_never_raises_on_bad_directory(tmp_path) -> None:
bad_dir = tmp_path / "some_file.txt"
bad_dir.write_text("not a directory")
logger = CanonicalAuditLogger(bad_dir / "audit")
logger.record(KIND_SECURITY_BLOCK, "x", False) # must not raise
def test_core_audit_log_wrapper_same_schema_as_before(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path)
monkeypatch.setattr(audit_log, "_logger",
audit_log.CanonicalAuditLogger(tmp_path))
audit_log.set_identity("carol", "machine-3", role="user")
audit_log.record("permission", "install_package", True, detail="ok", agent_role="cowork")
events = audit_log.load_events()
assert len(events) == 1
e = events[0]
assert set(e.keys()) == {"ts", "kind", "agent_role", "name", "ok", "detail",
"account", "role", "machine"}
assert e["kind"] == "permission"
assert e["name"] == "install_package"
assert e["ok"] is True
assert e["agent_role"] == "cowork"
assert e["account"] == "carol"
# Raw file on disk still uses the exact pre-refactor schema/keys.
raw_line = next((tmp_path).glob("*.jsonl")).read_text(encoding="utf-8").splitlines()[0]
raw = json.loads(raw_line)
assert list(raw.keys()) == ["ts", "kind", "agent_role", "name", "ok", "detail",
"account", "role", "machine"]
+97
View File
@@ -0,0 +1,97 @@
"""Mọi chỗ trong repo gán ``config.X = ...`` thì repository phải nhận được.
Bài này sinh ra từ một lỗi thật ngày 26/08.
R02 tráo ``config.py::AppConfig`` bằng ``JsonConfigRepository``. Trước khi
tráo tôi có đối chiếu API: đếm đủ 34/34 thành viên công khai, không thiếu cái
nào, nên kết luận là thay được. Đối chiếu đó **chỉ so tên**, không so việc một
``property`` có setter hay không.
``AppConfig`` cũ là dataclass, ``config.language = "vi"`` chạy bình thường.
Repository để ``language`` là property chỉ đọc, gán vào là ``AttributeError``.
Ba chỗ trong ``app.py`` đang gán như thế: đổi ngôn ngữ, đổi giao diện, đổi
provider trên thanh bên.
Điều làm nó khó thấy: cả ba đều nằm trong slot của Qt, mà Qt **nuốt ngoại lệ
trong slot**. Không có traceback, không có thông báo — người dùng bấm đổi ngôn
ngữ thì không có gì xảy ra. Bộ test đơn vị vẫn 709 xanh; chỉ ``check_nav`` bắt
được vì nó bấm thật vào combo rồi kiểm ngôn ngữ có đổi không.
Nên bài này đi ngược từ mã nguồn: tìm mọi chỗ gán, rồi thử gán thật.
"""
from __future__ import annotations
import copy
import re
import subprocess
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
#: ``config.X = ...`` nhưng không phải ``==``.
GAN = re.compile(r"\bconfig\.([a-z_][a-z_0-9]*)\s*=(?!=)")
#: Không phải thuộc tính cấu hình — là chỗ chứa chính đối tượng config.
BO_QUA = {"data", "config"}
def _cho_gan() -> set[str]:
out = subprocess.run(["git", "ls-files", "*.py"], cwd=REPO,
capture_output=True, text=True, encoding="utf-8",
errors="replace").stdout.split()
ten: set[str] = set()
for f in out:
p = REPO / f
if not p.is_file():
continue
for m in GAN.finditer(p.read_text(encoding="utf-8", errors="replace")):
if m.group(1) not in BO_QUA and not m.group(1).startswith("_"):
ten.add(m.group(1))
return ten
def _repo(tmp_path):
from cowork_local.config import DEFAULT_CONFIG
from cowork_local.infrastructure.config.json_config_repository import (
JsonConfigRepository,
)
return JsonConfigRepository.from_data(copy.deepcopy(DEFAULT_CONFIG),
tmp_path / "config.json")
def test_tim_duoc_cho_gan():
"""Bảo vệ chính bài test: biểu thức tìm kiếm hỏng thì nó lặng lẽ xanh."""
ten = _cho_gan()
assert ten, "không tìm thấy chỗ nào gán config.X — kiểm lại GAN"
assert "language" in ten, f"phải thấy config.language (thấy: {sorted(ten)})"
def test_moi_thuoc_tinh_bi_gan_deu_gan_duoc(tmp_path):
cfg = _repo(tmp_path)
hong = []
for ten in sorted(_cho_gan()):
if not hasattr(type(cfg), ten) and not hasattr(cfg, ten):
continue # thuộc tính của lớp khác, không phải config
cu = getattr(cfg, ten, None)
try:
setattr(cfg, ten, cu)
except AttributeError:
hong.append(ten)
assert not hong, (
"Repository không nhận gán, nhưng trong mã nguồn có chỗ gán:\n "
+ "\n ".join("config.%s = ..." % t for t in hong)
+ "\nQt nuốt AttributeError trong slot, nên chỗ đó sẽ im lặng không "
"làm gì. Thêm @<tên>.setter vào JsonConfigRepository."
)
@pytest.mark.parametrize("ten,gia_tri", [("language", "ja"), ("theme", "light"),
("active_provider", "ollama")])
def test_ba_cho_app_py_dang_gan(tmp_path, ten, gia_tri):
"""Chốt riêng ba cái app.py gán, để bài trên có hỏng thì vẫn còn lưới."""
cfg = _repo(tmp_path)
setattr(cfg, ten, gia_tri)
assert getattr(cfg, ten) == gia_tri
+60
View File
@@ -0,0 +1,60 @@
"""Task 4a — model_pricing <-> usage_tracker circular dependency is gone.
Before this fix, ``model_pricing.turn_cost_usd`` deferred-imported
``usage_tracker`` for its flat fallback rates, while ``usage_tracker.set_budget``
deferred-imported ``model_pricing`` for currency conversion — a real
architectural cycle, only avoided at runtime by pushing both imports inside
function bodies. Now ``model_pricing`` is a leaf module (it owns its own
fallback rates) and ``usage_tracker`` imports it at module top level.
"""
from __future__ import annotations
import copy
import sys
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.core import model_pricing, usage_tracker
def test_model_pricing_does_not_depend_on_usage_tracker_module_level() -> None:
assert "usage_tracker" not in model_pricing.__dict__
assert "usage_tracker" not in getattr(model_pricing, "__all__", [])
def test_usage_tracker_imports_model_pricing_at_top_level() -> None:
assert usage_tracker.mp is model_pricing
def test_turn_cost_usd_fallback_matches_pre_refactor_default_rates(tmp_path) -> None:
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
# No matching row in the price table and no override in config.data["usage"]
# -> falls back to the flat rates that used to live in
# usage_tracker.DEFAULT_PRICING (0.5 in / 1.5 out USD per 1M tokens).
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
assert cost == 0.5 + 1.5
def test_turn_cost_usd_honours_usage_override_like_before(tmp_path) -> None:
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
config.data.setdefault("usage", {})["price_per_mtok_in_usd"] = 2.0
config.data["usage"]["price_per_mtok_out_usd"] = 4.0
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
assert cost == 2.0 + 4.0
def test_set_budget_still_converts_via_model_pricing(tmp_path) -> None:
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
usage_tracker.set_budget(config, 100.0, currency="USD")
status = usage_tracker.budget_status(config)
assert status is not None
assert status["amount_usd"] == 100.0
def test_no_import_time_cycle_when_loaded_fresh() -> None:
for name in ("cowork_local.core.model_pricing", "cowork_local.core.usage_tracker"):
sys.modules.pop(name, None)
import importlib
mp = importlib.import_module("cowork_local.core.model_pricing")
ut = importlib.import_module("cowork_local.core.usage_tracker")
assert ut.mp is mp
+56
View File
@@ -0,0 +1,56 @@
"""Task 1 (sub-step 6d) — AgentStatusTab widget smoke test."""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.agent_status_tab import AgentStatusTab
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCowork:
def active_workers(self):
return [1, 2]
class _FakeTaskScheduler:
def running_count(self):
return 3
class _FakeCtx:
def __init__(self, tmp_path):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
self.config.data["agent_security"]["enabled"] = True
def test_agent_status_tab_has_no_search_or_detail(qapp, tmp_path) -> None:
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
assert not hasattr(tab, "filter_edit")
assert not hasattr(tab, "detail_panel")
def test_refresh_populates_six_rows_with_live_counts(qapp, tmp_path) -> None:
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None,
cowork=_FakeCowork(), task_scheduler=_FakeTaskScheduler())
tab.refresh()
assert tab.table.rowCount() == 6
assert tab.table.item(0, 0).text() # Cowork row has a label
assert tab.table.cellWidget(0, 1) is not None # badge pill widget
def test_retranslate_does_not_raise(qapp, tmp_path) -> None:
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
tab.retranslate()
+59
View File
@@ -0,0 +1,59 @@
"""Task 1 (sub-step 6c) — SecurityEventsTab / McpTab / ActionLogsTab.
Widget smoke tests against the offscreen QPA platform (see
test_monitoring_event_widgets.py for why no pytest-qt is needed). Verifies
each tab wires build_filter_scaffold correctly, exposes the attributes the
container's retranslate loop needs, and forwards set_events to its table.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.presentation.monitoring.tabs.action_logs_tab import ActionLogsTab
from cowork_local.presentation.monitoring.tabs.mcp_tab import McpTab
from cowork_local.presentation.monitoring.tabs.security_events_tab import SecurityEventsTab
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
@pytest.mark.parametrize("cls,expected_title_key", [
(SecurityEventsTab, "monitoring.security_events_title"),
(McpTab, "monitoring.mcp_history_title"),
(ActionLogsTab, "monitoring.action_logs_title"),
])
def test_tab_exposes_container_facing_attributes(qapp, cls, expected_title_key) -> None:
refresh_calls = []
tab = cls(ctx=None, on_refresh_all=lambda: refresh_calls.append(1))
assert tab.title_key == expected_title_key
assert tab.filter_edit is not None
assert tab.detail_panel is not None
tab.title_refresh_btn.click()
assert refresh_calls == [1]
def test_set_events_forwards_to_table(qapp) -> None:
tab = SecurityEventsTab(ctx=None, on_refresh_all=lambda: None)
tab.set_events([{"ts": "2026-01-01T00:00:00", "kind": "security_block", "name": "x",
"ok": False, "detail": "d", "account": "a", "machine": "m"}])
assert tab.table.rowCount() == 1
def test_retranslate_does_not_raise(qapp) -> None:
tab = McpTab(ctx=None, on_refresh_all=lambda: None)
tab.retranslate() # must not raise
def test_ai_filter_noop_when_search_box_empty(qapp) -> None:
tab = ActionLogsTab(ctx=None, on_refresh_all=lambda: None)
tab.ai_filter_btn.click() # empty search text -> start_ai_filter no-ops, must not raise
+79
View File
@@ -0,0 +1,79 @@
"""Task 1 (sub-step 6b) — EventTable / EventDetailPanel widget smoke tests.
These instantiate real PySide6 widgets against the offscreen QPA platform
(no display needed, no pytest-qt dependency — a plain QApplication instance
is enough to construct/query widgets, only an actual event loop would need
more). Verifies the extraction into presentation/monitoring/shared/ wires up
without error and preserves the pre-refactor row/column behaviour.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.presentation.monitoring.shared.event_detail_panel import EventDetailPanel
from cowork_local.presentation.monitoring.shared.event_table import EventTable
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def _sample_event(**overrides):
ev = {"ts": "2026-05-25T15:03:00", "kind": "security_block", "name": "run_command",
"ok": False, "detail": "blocked it", "account": "alice", "machine": "m1",
"agent_role": "cowork"}
ev.update(overrides)
return ev
def test_event_table_security_events_hides_result_column(qapp) -> None:
table = EventTable(show_result=False)
table.retranslate()
assert table.columnCount() == 6
def test_event_table_generic_shows_result_column(qapp) -> None:
table = EventTable(show_result=True)
table.retranslate()
assert table.columnCount() == 7
def test_set_events_populates_rows_newest_first(qapp) -> None:
table = EventTable(show_result=True)
table.set_events([
_sample_event(ts="2026-05-25T10:00:00", name="first"),
_sample_event(ts="2026-05-25T12:00:00", name="second"),
])
assert table.rowCount() == 2
assert table.item(0, 4).text() == "second"
assert table.item(1, 4).text() == "first"
def test_event_at_row_round_trips_full_event(qapp) -> None:
table = EventTable(show_result=False)
ev = _sample_event()
table.set_events([ev])
assert table.event_at_row(0) == ev
def test_apply_filter_hides_non_matching_rows(qapp) -> None:
table = EventTable(show_result=True)
table.set_events([_sample_event(name="run_command"), _sample_event(name="fetch_url")])
table.apply_filter("fetch")
hidden = [table.isRowHidden(r) for r in range(table.rowCount())]
assert hidden.count(True) == 1
def test_detail_panel_shows_event_without_error(qapp) -> None:
panel = EventDetailPanel()
panel.retranslate()
panel.show_event(_sample_event(), 0)
assert panel._detail_text == "blocked it"
+80
View File
@@ -0,0 +1,80 @@
"""Task 1 (sub-step 6f) — OverviewTab widget smoke test."""
from __future__ import annotations
import copy
import os
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.overview_tab import OverviewTab
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCtx:
def __init__(self, tmp_path):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
self.started_at = time.time() - 30
def save(self):
pass
def cowork_output_dir(self):
return "."
def _make_tab(tmp_path):
return OverviewTab(
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
action_logs_tab_visible=True)
def test_construction_and_retranslate(qapp, tmp_path) -> None:
tab = _make_tab(tmp_path)
tab.retranslate() # must not raise
def test_refresh_with_no_events_shows_no_activity_text(qapp, tmp_path) -> None:
tab = _make_tab(tmp_path)
tab.retranslate()
tab.refresh(events=[])
assert tab.activity_lbl.text() != ""
def test_refresh_with_events_renders_activity_lines(qapp, tmp_path) -> None:
tab = _make_tab(tmp_path)
tab.retranslate()
tab.refresh(events=[
{"ts": "2026-01-01T00:00:00", "kind": "tool_call", "name": "run_command", "ok": True},
{"ts": "2026-01-01T00:00:05", "kind": "security_block", "name": "blocked", "ok": False},
])
assert "run_command" in tab.activity_lbl.text() or "blocked" in tab.activity_lbl.text()
def test_view_all_button_invokes_callback(qapp, tmp_path) -> None:
calls = []
tab = OverviewTab(
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: calls.append(1),
action_logs_tab_visible=True)
tab.view_all_btn.click()
assert calls == [1]
def test_audit_group_visibility_follows_constructor_flag(qapp, tmp_path) -> None:
hidden_tab = OverviewTab(
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
action_logs_tab_visible=False)
assert hidden_tab.audit_group.isHidden() is True
+49
View File
@@ -0,0 +1,49 @@
"""Task 1 (sub-step 6f, follow-up) — PricingPanel, split out of overview_tab.py
to keep that file under the 400-line quality rule."""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.pricing_panel import PricingPanel
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCtx:
def __init__(self, tmp_path):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
def save(self):
pass
def test_construction_and_retranslate(qapp, tmp_path) -> None:
panel = PricingPanel(_FakeCtx(tmp_path), on_status_message=lambda _m: None)
panel.retranslate()
def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
from PySide6.QtWidgets import QInputDialog
ctx = _FakeCtx(tmp_path)
panel = PricingPanel(ctx, on_status_message=lambda _m: None)
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True)))
panel._add_pricing_row()
assert panel.table.rowCount() == 1
assert panel.table.item(0, 0).text() == "gpt-test"
panel.table.selectRow(0)
panel._delete_pricing_row()
assert panel.table.rowCount() == 0
+95
View File
@@ -0,0 +1,95 @@
"""Task 3 — MonitoringQueryService: read-only filter/sort/pagination over
audit events, fully testable without file I/O (InMemoryAuditEventRepository)
and with a real CanonicalAuditLogger wired through CanonicalAuditEventRepository.
"""
from __future__ import annotations
from cowork_local.application.monitoring.dto.audit_event_dto import AuditEventDTO
from cowork_local.application.monitoring.monitoring_query_service import (
MonitoringQueryService,
)
from cowork_local.application.monitoring.repository.audit_event_repository import (
CanonicalAuditEventRepository,
InMemoryAuditEventRepository,
)
from cowork_local.infrastructure.telemetry.audit_logger import CanonicalAuditLogger
def _event(ts, kind="tool_call", name="x", ok=True, detail="") -> AuditEventDTO:
return AuditEventDTO(ts=ts, kind=kind, name=name, ok=ok, detail=detail)
def test_query_filters_by_kind() -> None:
repo = InMemoryAuditEventRepository([
_event("2026-01-01T00:00:00", kind="mcp_call", name="a"),
_event("2026-01-01T00:00:01", kind="security_block", name="b"),
])
service = MonitoringQueryService(repo)
page = service.query(kind="mcp_call")
assert [e.name for e in page.items] == ["a"]
def test_query_filters_by_ok_and_text() -> None:
repo = InMemoryAuditEventRepository([
_event("2026-01-01T00:00:00", name="run_command", ok=False, detail="blocked"),
_event("2026-01-01T00:00:01", name="run_command", ok=True, detail="fine"),
_event("2026-01-01T00:00:02", name="fetch_url", ok=False, detail="blocked"),
])
service = MonitoringQueryService(repo)
page = service.query(ok=False, text="run_command")
assert len(page.items) == 1
assert page.items[0].detail == "blocked"
def test_query_sorts_newest_first_by_default() -> None:
repo = InMemoryAuditEventRepository([
_event("2026-01-01T00:00:00", name="first"),
_event("2026-01-02T00:00:00", name="second"),
])
service = MonitoringQueryService(repo)
page = service.query()
assert [e.name for e in page.items] == ["second", "first"]
def test_query_paginates() -> None:
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 11)]
repo = InMemoryAuditEventRepository(events)
service = MonitoringQueryService(repo)
page1 = service.query(sort_by="ts", descending=False, page=1, page_size=4)
page2 = service.query(sort_by="ts", descending=False, page=2, page_size=4)
assert page1.total == 10
assert [e.name for e in page1.items] == ["1", "2", "3", "4"]
assert [e.name for e in page2.items] == ["5", "6", "7", "8"]
assert page1.has_more is True
def test_large_page_size_returns_everything_matching_current_ui_behaviour() -> None:
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 6)]
repo = InMemoryAuditEventRepository(events)
service = MonitoringQueryService(repo)
page = service.query(page_size=10_000)
assert len(page.items) == 5
assert page.has_more is False
def test_repository_is_read_only_no_pyside6_import() -> None:
import cowork_local.application.monitoring.monitoring_query_service as mod
import cowork_local.application.monitoring.repository.audit_event_repository as repo_mod
assert "PySide6" not in mod.__dict__
assert "PySide6" not in repo_mod.__dict__
assert not hasattr(mod.MonitoringQueryService, "record")
def test_canonical_repository_wires_to_real_logger(tmp_path) -> None:
logger = CanonicalAuditLogger(tmp_path)
logger.record("security_block", "run_command", False, detail="nope")
logger.record("mcp_call", "search", True)
repo = CanonicalAuditEventRepository(logger)
service = MonitoringQueryService(repo)
page = service.query(kind="security_block")
assert len(page.items) == 1
assert page.items[0].name == "run_command"
@@ -0,0 +1,73 @@
"""Task 1 (sub-step 6e) — SandboxDetailsCard / PermissionsCard.
Verifies the Permissions card is nested INSIDE the Sandbox card's fold
(matching the pre-refactor layout exactly) and that refresh()/retranslate()
compute the same values the original MonitoringTab methods did.
"""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.sandbox_tab import SandboxDetailsCard
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCtx:
def __init__(self, tmp_path, block_network=False):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
self.config.data["agent_security"]["block_network"] = block_network
self.config.data["agent_security"]["resource_limit_cpu_percent"] = 50
import time
self.started_at = time.time() - 65 # ~1m5s uptime
def test_permissions_card_nested_inside_sandbox_fold(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
assert card.permissions_card.parent() is card._detail
def test_refresh_computes_uptime_and_resource_limits(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
card.retranslate()
card.refresh()
assert "CPU 50%" in card.limits_lbl.text()
assert "m" in card.uptime_val.text()
def test_refresh_reflects_network_blocked_on_both_cards(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=True), on_settings_changed=lambda: None)
card.retranslate()
card.refresh()
assert card.net_val.objectName() == "badgeWarn"
assert card.permissions_card.network_val.objectName() == "badgeWarn"
def test_refresh_reflects_network_allowed(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=False), on_settings_changed=lambda: None)
card.retranslate()
card.refresh()
assert card.net_val.objectName() == "badgeSuccess"
assert card.permissions_card.network_val.objectName() == "badgeSuccess"
def test_fold_starts_collapsed(qapp, tmp_path) -> None:
# isVisible() alone can't tell (it also depends on ancestors actually
# being shown on screen, which nothing here is) — isHidden() reflects
# the explicit setVisible(False) call regardless of the parent chain.
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
assert card._detail.isHidden() is True
card.more_btn.setChecked(True)
assert card._detail.isHidden() is False
+78
View File
@@ -0,0 +1,78 @@
"""Task 1 (sub-step 6a) — presentation/monitoring/shared helpers.
Only the pure-Python functions are covered here (no PySide6 widget
instantiation needed, no pytest-qt required). Values are asserted against
what ``ui/monitoring_tab.py``'s original, now-removed private functions
produced, so this doubles as a characterization test proving the extraction
didn't change output.
"""
from __future__ import annotations
from cowork_local.presentation.monitoring.shared import badges, formatters
def test_fmt_bytes() -> None:
assert formatters.fmt_bytes(500) == "500 B"
assert formatters.fmt_bytes(2048) == "2 KB"
def test_fmt_event_time_and_full_and_relative_are_unparsable_safe() -> None:
assert formatters.fmt_event_time("not-a-timestamp") == "not-a-timestamp"
assert formatters.fmt_event_time_full("not-a-timestamp") == "not-a-timestamp"
assert formatters.relative_time("not-a-timestamp") == ""
def test_fmt_event_time_formats_valid_iso_timestamp() -> None:
assert formatters.fmt_event_time("2026-05-25T15:03:00") == "25/05 15:03"
# The separator is computed via datetime.strftime with a literal
# non-ASCII character in the format string, same as the pre-refactor
# ui/monitoring_tab.py code — on a Windows box whose locale ANSI codepage
# has no direct mapping for U+00B7 (MIDDLE DOT), strftime's encode/decode
# round trip through that codepage can substitute a different but
# visually similar character (observed: U+30FB on a ja_JP/cp932 locale).
# That behavior is unchanged by this refactor either way, so the test
# computes the expected separator the exact same way the implementation
# does, rather than assuming byte 0xB7 survives on every locale.
import datetime as _dt
expected_full = _dt.datetime(2026, 5, 25, 15, 3, 7).strftime(
f"%d/%m/%Y {formatters._MIDDLE_DOT} %H:%M:%S")
assert formatters.fmt_event_time_full("2026-05-25T15:03:07") == expected_full
def test_event_id_shape() -> None:
assert formatters.event_id("2026-05-25T15:03:00", 7) == "evt_202605251503_007"
def test_agent_initials() -> None:
assert formatters.agent_initials("Cowork Agent") == "CA"
assert formatters.agent_initials("graphrag") == "G"
def test_agent_avatar_colour_identity_mapping() -> None:
assert formatters.agent_avatar_colour("Security Agent") == "#D13438"
assert formatters.agent_avatar_colour("Cowork Agent") == "#0078D4"
assert formatters.agent_avatar_colour("unknown-agent") == "#0078D4"
def test_action_label_falls_back_to_raw_name_when_unmapped() -> None:
assert badges.action_label("some_custom_tool") == "some_custom_tool"
def test_status_info_security_block_unmapped_defaults_to_blocked() -> None:
tone, key = badges.status_info("some_new_rule", kind="security_block", ok=False)
assert (tone, key) == ("badgePurple", "monitoring.status_blocked")
def test_status_info_mcp_call_falls_back_to_ok_flag() -> None:
assert badges.status_info("search", kind="mcp_call", ok=True) == ("badgeSuccess", "monitoring.status_ok")
assert badges.status_info("search", kind="mcp_call", ok=False) == ("badgeDanger", "monitoring.status_failed")
def test_severity_info_dangerous_command_is_critical() -> None:
assert badges.severity_info("run_command") == ("badgeDanger", "monitoring.severity_critical")
def test_agent_badge_name_identity_mapping() -> None:
assert badges.agent_badge_name("Security Agent") == "badgeDanger"
assert badges.agent_badge_name("graphrag") == "badgePurple"
assert badges.agent_badge_name("unknown") == "badge"
+97
View File
@@ -0,0 +1,97 @@
"""Task 1 (sub-step 6g) — the new presentation/monitoring/monitoring_tab.py
container. Builds a real MonitoringTab against a real AppConfig/AppContext
(same convention as tests/routing/test_service.py: real config/context, only
the true external collaborators — here, none — get a fake), and verifies the
public API app.py depends on is intact: constructor signature,
``status_message`` signal, ``select_subtab``, ``nav_subtabs``,
``hide_tab_bar``.
"""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.monitoring_tab import MonitoringTab
from cowork_local.state import AppContext
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture()
def ctx(tmp_path):
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
return AppContext(config)
def test_constructor_accepts_apps_py_call_signature(qapp, ctx) -> None:
# app.py:499-502 calls MonitoringTab(self.ctx, cowork=..., structure=...,
# task_scheduler=...) — all optional besides ctx.
tab = MonitoringTab(ctx)
assert tab.tabs.count() >= 1
def test_status_message_signal_exists(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
received = []
tab.status_message.connect(received.append)
tab.status_message.emit("hello")
assert received == ["hello"]
def test_select_subtab_and_nav_subtabs(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
subtabs = tab.nav_subtabs()
assert len(subtabs) == tab.tabs.count()
tab.select_subtab(1)
assert tab.tabs.currentIndex() == 1
def test_hide_tab_bar_does_not_raise(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
tab.hide_tab_bar()
def test_full_refresh_populates_event_tabs_via_query_service(qapp, ctx, monkeypatch) -> None:
from cowork_local.core import audit_log
audit_dir = ctx.config.path.parent / "audit"
monkeypatch.setattr(audit_log, "AUDIT_DIR", audit_dir)
monkeypatch.setattr(audit_log, "_logger", audit_log.CanonicalAuditLogger(audit_dir))
audit_log.record("security_block", "run_command", False, detail="blocked")
audit_log.record("mcp_call", "search", True)
audit_log.record("tool_call", "read_file", True)
tab = MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)
tab.refresh()
assert tab.security_tab.table.rowCount() == 1
assert tab.mcp_tab.table.rowCount() == 1
assert tab.action_tab.table.rowCount() == 3
def test_retranslate_does_not_raise(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
tab._retranslate()
def test_settings_changed_callback_is_the_container_full_refresh(qapp, ctx) -> None:
# Editing Settings from either the Sandbox or the nested Permissions
# card's "Edit" button used to call the same
# MonitoringTab._open_settings_and_refresh -> self.refresh(); the
# container now wires both cards' on_settings_changed to its own
# bound refresh method, so this equality is the direct replacement
# for that identity.
tab = MonitoringTab(ctx)
assert tab.overview_tab.sandbox_card._on_settings_changed == tab.refresh
assert tab.overview_tab.sandbox_card.permissions_card._on_settings_changed == tab.refresh
+101
View File
@@ -0,0 +1,101 @@
"""Task 5 — Sandbox Capability Matrix: pure OS/risk-tier policy, no execution,
no PySide6, no dependency on core/sandbox_manager.py.
"""
from __future__ import annotations
from cowork_local.infrastructure.sandbox import sandbox_capabilities as sc
def test_detect_os_from_injected_platform_name() -> None:
assert sc.detect_os("win32") == sc.WINDOWS
assert sc.detect_os("linux") == sc.LINUX
assert sc.detect_os("darwin") == sc.MACOS
assert sc.detect_os("some-other-os") == sc.UNKNOWN
def test_windows_matches_todays_real_backends() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
names = {b.name for b in matrix.available_backends()}
assert names == {"direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"}
def test_windows_routing_matches_core_sandbox_manager_today() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
assert matrix.select_backend(sc.SAFE) == "integrity_job_wfp"
assert matrix.select_backend(sc.MODERATE) == "integrity_job_wfp"
assert matrix.select_backend(sc.HIGH) == "appcontainer"
assert matrix.select_backend(sc.CRITICAL) == "windows_sandbox"
def test_linux_has_no_real_backend_yet() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.LINUX)
available = {b.name for b in matrix.available_backends()}
assert available == {"direct"} # namespaces_bubblewrap declared but not implemented
assert matrix.select_backend(sc.SAFE) == "direct" # SAFE/MODERATE only ever wanted direct
# HIGH's preferred backend (namespaces_bubblewrap) isn't implemented, and
# HIGH's routing table names "blocked" as the explicit next preference
# (not a silent fallback to unisolated "direct") — a HIGH-risk command
# must never quietly downgrade to no isolation just because the real
# sandbox backend is missing on this OS.
assert matrix.select_backend(sc.HIGH) == "blocked"
assert matrix.select_backend(sc.CRITICAL) == "blocked"
def test_macos_has_no_real_backend_yet() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.MACOS)
available = {b.name for b in matrix.available_backends()}
assert available == {"direct"}
def test_disallowing_direct_fallback_blocks_instead() -> None:
# LINUX's own HIGH routing already names "blocked" explicitly, so it
# doesn't exercise the allow_direct_fallback branch. Register a profile
# whose HIGH tier names only an unavailable backend (no explicit
# "direct"/"blocked" entry) to exercise the bottom-of-select_backend
# fallback path directly.
os_name = "test-os-fallback"
profile = sc.OsSandboxProfile(
operating_system=os_name,
backends=(sc.SandboxBackend("direct", "none", True),),
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
sc.HIGH: ("not_yet_implemented",), sc.CRITICAL: ("not_yet_implemented",)},
)
sc.register_profile(profile)
try:
allowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=True)
disallowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=False)
assert allowed.select_backend(sc.HIGH) == "direct"
assert disallowed.select_backend(sc.HIGH) == "blocked"
# CRITICAL never falls back to direct even when allowed.
assert allowed.select_backend(sc.CRITICAL) == "blocked"
finally:
del sc._PROFILES[os_name]
def test_unknown_os_always_blocks() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.UNKNOWN)
assert matrix.available_backends() == ()
for tier in (sc.SAFE, sc.MODERATE, sc.HIGH, sc.CRITICAL):
assert matrix.select_backend(tier) == "blocked"
def test_registering_a_brand_new_os_requires_no_class_changes() -> None:
freebsd = "freebsd"
profile = sc.OsSandboxProfile(
operating_system=freebsd,
backends=(sc.SandboxBackend("direct", "none", True),),
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
sc.HIGH: ("blocked",), sc.CRITICAL: ("blocked",)},
)
sc.register_profile(profile)
try:
matrix = sc.SandboxCapabilityMatrix(operating_system=freebsd)
assert matrix.select_backend(sc.SAFE) == "direct"
assert matrix.select_backend(sc.HIGH) == "blocked"
finally:
del sc._PROFILES[freebsd] # don't leak state into other tests
def test_no_pyside6_or_subprocess_dependency() -> None:
assert "PySide6" not in sc.__dict__
assert "subprocess" not in sc.__dict__
View File
+19
View File
@@ -0,0 +1,19 @@
"""Dựng Qt ở chế độ offscreen cho test giao diện.
Offscreen là bắt buộc, không phải cho nhanh: máy dev là máy làm việc thật của
người dùng. Test bật cửa sổ lên là nó nhảy ra trước mặt, che thứ đang mở.
"""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@pytest.fixture(scope="session")
def qapp():
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
+149
View File
@@ -0,0 +1,149 @@
"""Đặc tả hành vi SettingsDialog TRƯỚC khi tách — R08-T07.
Không phải test tính năng mới. Đây là lưới an toàn: chốt lại dialog hiện
đang làm gì, để khi bóc 727 dòng thành các widget con còn biết mình có làm
lệch đi chỗ nào không. Bài nào ở đây đỏ sau khi tách nghĩa là tách sai.
"""
from __future__ import annotations
import pytest
class _Config:
"""Đủ dùng cho SettingsDialog, không hơn — xem danh sách ctx.* nó chạm."""
def __init__(self):
self.data = {
"active_provider": "openai_compat",
"language": "vi",
"theme": "dark",
"providers": {
"openai_compat": {"base_url": "https://api.openai.com/v1",
"api_key": "khoa-cu", "model": "gpt-4o"},
"ollama": {"base_url": "http://localhost:11434",
"api_key": "ollama", "model": "qwen2.5-coder"},
},
"tray": {"minimize_on_close": True, "notify_on_done": False},
"agent_security": {
"enabled": True, "cowork_confirm_commands": False,
"block_network": True, "command_ai_check": False,
"resource_limit_cpu_percent": 55,
"resource_limit_memory_mb": 1024,
"resource_limit_disk_mb": 2048,
},
"attachments": {"max_tokens": 32000, "max_files": 7},
"structure": {"max_nodes": 300, "max_edges": 600},
"routing": {"switch_mode": "auto", "policy": "cost",
"min_score_gain": 0.05, "confirm_timeout_sec": 90,
"reassess_interval_hours": 12,
"per_provider_concurrency": 3,
"judge_model": "gpt-4o-mini"},
}
self._data = self.data
self._agent_security = self.data["agent_security"]
language = property(lambda self: self.data["language"])
theme = property(lambda self: self.data["theme"])
active_provider = property(lambda self: self.data["active_provider"])
agent_security = property(lambda self: self.data["agent_security"])
routing = property(lambda self: self.data["routing"])
class _Ctx:
def __init__(self):
self.config = _Config()
self.routing = None
self.saves = 0
def save(self):
self.saves += 1
@pytest.fixture
def dialog(qapp):
from cowork_local.ui.settings_dialog import SettingsDialog
ctx = _Ctx()
dlg = SettingsDialog(ctx)
yield dlg, ctx
dlg.deleteLater()
# ---- dialog gồm những mục nào -------------------------------------------
def test_co_dung_nam_muc(dialog):
"""Năm mục thật trên màn hình. Plan R08-T07 ghi bốn widget và có một cái
tên `connector`, nhưng UI connector đã dời khỏi Settings từ trước (xem
ghi chú ở settings_dialog.py:180) — nên con số thật là năm, không bốn."""
dlg, _ = dialog
labels = [dlg.section_list.item(i).text()
for i in range(dlg.section_list.count())]
assert len(labels) == 5, labels
assert dlg.section_stack.count() == 5
def test_moi_muc_deu_bam_duoc(dialog):
dlg, _ = dialog
for i in range(dlg.section_list.count()):
dlg.section_list.setCurrentRow(i)
assert dlg.section_stack.currentIndex() == i
# ---- nạp giá trị từ config ----------------------------------------------
def test_nap_dung_gia_tri_dang_co(dialog):
dlg, ctx = dialog
assert dlg.language_combo.currentData() == "vi"
assert dlg.theme_combo.currentData() == "dark"
assert dlg.provider_combo.currentData() == "openai_compat"
assert dlg.prov_base.text() == "https://api.openai.com/v1"
assert dlg.tray_chk.isChecked() is True
assert dlg.notify_chk.isChecked() is False
assert dlg.routing_mode.currentData() == "auto"
assert dlg.routing_policy.currentData() == "cost"
assert dlg.routing_timeout.value() == 90
assert dlg.attach_files.value() == 7
assert dlg.struct_nodes.value() == 300
assert dlg.sandbox_cpu.value() == 55
def test_khoa_api_khong_hien_ro(dialog):
"""QLineEdit.Password — khoá không được đọc được bằng mắt qua vai."""
from PySide6.QtWidgets import QLineEdit
dlg, _ = dialog
assert dlg.prov_key.echoMode() == QLineEdit.Password
# ---- lưu ghi ra đúng chỗ -------------------------------------------------
def test_luu_ghi_dung_moi_o(dialog):
dlg, ctx = dialog
dlg.language_combo.setCurrentIndex(
dlg.language_combo.findData("en") if dlg.language_combo.findData("en") >= 0 else 0)
dlg.tray_chk.setChecked(False)
dlg.routing_timeout.setValue(120)
dlg.attach_files.setValue(3)
dlg.sandbox_cpu.setValue(80)
dlg._save()
d = ctx.config.data
assert d["tray"]["minimize_on_close"] is False
assert d["routing"]["confirm_timeout_sec"] == 120
assert d["attachments"]["max_files"] == 3
assert d["agent_security"]["resource_limit_cpu_percent"] == 80
assert ctx.saves == 1
def test_luu_doi_min_gain_tu_phan_tram_sang_phan_le(dialog):
"""Ô nhập là %, config lưu số thập phân. Đây là chỗ dễ tách sai nhất."""
dlg, ctx = dialog
dlg.routing_min_gain.setValue(25)
dlg._save()
assert ctx.config.data["routing"]["min_score_gain"] == 0.25
def test_luu_xoa_cache_de_app_doc_lai_ngay(dialog):
dlg, ctx = dialog
dlg._save()
assert ctx.config._data is None
assert ctx.config._agent_security is None
+5 -782
View File
@@ -34,332 +34,25 @@ original value, so the deviation is auditable rather than silent.
"""
from __future__ import annotations
from .theme_palettes import ( # noqa: F401 — giữ đường vào cũ
DARK, LIGHT, Palette, _chevron_asset, _FONT, _MONO, _PALETTES,
)
from .theme_qss import _TEMPLATE
from dataclasses import dataclass, asdict
from string import Template
def _chevron_asset(direction: str, color: str) -> str:
"""Render (and cache on disk) a small chevron PNG for QComboBox/QSpinBox
arrow subcontrols. QSS's ``image:`` property only accepts a resource or
file path, never a live QPixmap — and once ``::drop-down``/``::up-button``/
``::down-button`` are styled at all, Qt stops drawing its own built-in
arrow, so without this the controls show no affordance whatsoever."""
import hashlib
import tempfile
from pathlib import Path
key = hashlib.md5(f"{direction}-{color}".encode()).hexdigest()[:10]
path = Path(tempfile.gettempdir()) / "cowork_local_theme" / f"chevron_{key}.png"
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
from PySide6.QtCore import QPointF, Qt
from PySide6.QtGui import QColor, QPainter, QPixmap
size = 12
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
pen = p.pen()
pen.setColor(QColor(color))
pen.setWidthF(1.6)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
p.setPen(pen)
pts = ([QPointF(2.5, 4.5), QPointF(6, 8), QPointF(9.5, 4.5)] if direction == "down"
else [QPointF(2.5, 7.5), QPointF(6, 4), QPointF(9.5, 7.5)])
p.drawPolyline(pts)
p.end()
pm.save(str(path))
return path.as_posix()
@dataclass(frozen=True)
class Palette:
"""Every colour and shape value the interface is allowed to use."""
name: str
# --- surfaces: a 4-step ramp from the window back to the frontmost layer.
bg: str # window / canvas backdrop
surface: str # panels, cards, group boxes (NOT the nav rail)
surface_raised: str # inputs, lists, trees — things you type or pick in
overlay: str # menus, tooltips, popups (floats above everything)
sunken: str # logs, code, terminals — things you read into
hover: str # hover wash on rows, tabs, ghost buttons
active: str # pressed / held state
# The nav rail gets its own step rather than borrowing `surface`. It is a
# permanent region of the window, not a card floating on the page.
#
# Following VS Code, the rail is *darker* than the content area (dark) or a
# shade off white (light). The step is small on purpose — VS Code separates
# the rail with a border, not a big tonal jump — so `nav_border` is doing
# real work here and must stay visible.
nav_bg: str
nav_border: str
nav_hover: str
nav_selected: str
# --- lines
border: str # default hairline
border_strong: str # hairline that must survive next to a filled surface
focus_ring: str # keyboard/typing focus
# --- text
text: str
text_muted: str # secondary copy, captions, group-box titles
text_faint: str # metadata, timestamps, placeholder
text_disabled: str
on_accent: str # text drawn on top of a filled accent/status surface
# --- accent: `accent` tints text & icons, `accent_solid` fills buttons.
accent: str
accent_solid: str
accent_solid_hover: str
accent_solid_active: str
accent_soft: str # translucent wash for selected rows (QSS only)
accent_soft_hover: str
accent_wash: str # the same tint pre-blended to a solid, for Qt rich
# text (bgcolor=, <table>) where alpha is ignored
# --- status
success: str
success_soft: str
warning: str
warning_soft: str
danger: str
danger_solid: str
danger_solid_hover: str
danger_soft: str
info: str
info_soft: str
purple: str
purple_soft: str
pink: str
pink_soft: str
# --- selection (text selection inside editors and inputs)
selection_bg: str
selection_fg: str
# --- scrollbars
scroll_handle: str
scroll_handle_hover: str
# --- code & terminal
code_bg: str
code_fg: str
code_gutter_bg: str
code_gutter_fg: str
code_selection: str
code_comment: str
code_keyword: str
code_type: str
code_func: str
code_attr: str
code_string: str
code_number: str
code_error: str
# --- diff / inline change badges
diff_add_bg: str
diff_add_fg: str
diff_del_bg: str
diff_del_fg: str
# --- charts
chart_grid: str
chart_label: str
# --- conversation & graph node roles
role_user: str
role_assistant: str
role_tool: str
role_result: str
role_error: str
# --- shape & type
radius_sm: int
radius: int
radius_lg: int
font_family: str
font_size: int
font_mono: str
_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif'
_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace'
DARK = Palette(
name="dark",
# ---- VS Code "Dark Modern" ----------------------------------------------
# Values taken from the shipped theme JSON. Where VS Code's own choice falls
# below WCAG AA it is nudged just far enough to pass; each such value carries
# a note with VS Code's original and the measured ratio.
bg="#1F1F1F", # editor.background
surface="#252526", # panel / card
surface_raised="#313131", # input.background
overlay="#252526", # menus, tooltips
sunken="#181818", # logs, terminals — below the ramp
hover="#2A2D2E", # list.hoverBackground
active="#37373D", # list.inactiveSelectionBackground
# The sidebar is DARKER than the editor — that is the VS Code silhouette.
nav_bg="#181818", # sideBar.background
nav_border="#2B2B2B", # sideBar.border
nav_hover="#2A2D2E",
nav_selected="#04395E", # list.activeSelectionBackground
border="#2B2B2B", # panel.border
border_strong="#3C3C3C", # input.border
focus_ring="#0078D4", # focusBorder
text="#CCCCCC", # editor.foreground
text_muted="#9D9D9D", # descriptionForeground
text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces
text_disabled="#5A5A5A",
on_accent="#FFFFFF",
accent="#4DAAFC", # textLink.foreground — accent as TEXT
accent_solid="#0078D4", # button.background — accent as FILL
accent_solid_hover="#026EC1",
accent_solid_active="#005FB8",
accent_soft="rgba(0,120,212,0.22)",
accent_soft_hover="rgba(0,120,212,0.32)",
accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha
success="#89D185", # gitDecoration added
success_soft="rgba(137,209,133,0.16)",
warning="#CCA700", # editorWarning
warning_soft="rgba(204,167,0,0.16)",
danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels)
danger_solid="#C4302B",
danger_solid_hover="#D9433C",
danger_soft="rgba(241,76,76,0.16)",
info="#4DAAFC",
info_soft="rgba(77,170,252,0.16)",
purple="#C586C0", # Dark+ syntax purple
purple_soft="rgba(197,134,192,0.16)",
pink="#D16D9E",
pink_soft="rgba(209,109,158,0.16)",
selection_bg="#264F78", # editor.selectionBackground
selection_fg="#FFFFFF",
scroll_handle="#4E4E4E", # scrollbarSlider
scroll_handle_hover="#5A5A5A",
code_bg="#1F1F1F",
code_fg="#CCCCCC",
code_gutter_bg="#1F1F1F",
# VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA.
code_gutter_fg="#858D97",
code_selection="#264F78",
code_comment="#6A9955", # ---- Dark+ syntax, unchanged --------------
code_keyword="#569CD6",
code_type="#4EC9B0",
code_func="#DCDCAA",
code_attr="#9CDCFE",
code_string="#CE9178",
code_number="#B5CEA8",
code_error="#F44747",
diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended
diff_add_fg="#89D185",
diff_del_bg="#4B1818", # diffEditor removed, pre-blended
diff_del_fg="#F76464",
chart_grid="#2B2B2B",
chart_label="#9D9D9D",
role_user="#4DAAFC",
role_assistant="#4EC9B0",
role_tool="#C586C0",
role_result="#89D185",
role_error="#F14C4C",
radius_sm=3, # VS Code is squarer than the previous look
radius=4,
radius_lg=6,
font_family=_FONT,
font_size=13,
font_mono=_MONO,
)
LIGHT = Palette(
name="light",
# ---- VS Code "Light Modern" ---------------------------------------------
bg="#FFFFFF", # editor.background
surface="#F8F8F8", # sideBar / panel
surface_raised="#FFFFFF", # input.background
overlay="#FFFFFF",
sunken="#F3F3F3",
hover="#F2F2F2", # list.hoverBackground
active="#E8E8E8", # list.activeSelectionBackground
nav_bg="#F8F8F8", # sideBar.background
nav_border="#E5E5E5", # sideBar.border
nav_hover="#F2F2F2",
nav_selected="#E4E6F1", # active row, tinted toward the accent
border="#E5E5E5",
border_strong="#CECECE", # input.border
focus_ring="#005FB8", # focusBorder
text="#3B3B3B", # editor.foreground
text_muted="#616161",
# VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there.
text_faint="#6E6E6E",
text_disabled="#A0A0A0",
on_accent="#FFFFFF",
accent="#005FB8", # textLink / button
accent_solid="#005FB8",
accent_solid_hover="#0258A8",
accent_solid_active="#004C97",
accent_soft="rgba(0,95,184,0.10)",
accent_soft_hover="rgba(0,95,184,0.16)",
accent_wash="#E6EEF8",
# VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted.
success="#317A2D",
success_soft="#DFF3DE",
warning="#8F6500", # VS Code #BF8803 = 3.12:1
warning_soft="#FBF0D0",
danger="#CD3131", # editorError
danger_solid="#CD3131",
danger_solid_hover="#B82A2A",
danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it
info="#005FB8",
info_soft="#DDEBF9",
purple="#6F42C1",
purple_soft="#EDE7FA",
pink="#B3247E",
pink_soft="#FAE3F0",
selection_bg="#ADD6FF", # editor.selectionBackground
selection_fg="#000000",
scroll_handle="#C1C1C1",
scroll_handle_hover="#A6A6A6",
code_bg="#FFFFFF",
code_fg="#3B3B3B",
code_gutter_bg="#F8F8F8",
code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter
code_selection="#ADD6FF",
code_comment="#008000", # ---- Light+ syntax ------------------------
code_keyword="#0000FF",
code_type="#267F99",
code_func="#795E26",
code_attr="#E50000",
code_string="#A31515",
code_number="#098658",
code_error="#CD3131",
diff_add_bg="#DBF4DB",
diff_add_fg="#1E6F1A",
diff_del_bg="#FBE3E3",
diff_del_fg="#B82A2A",
chart_grid="#E5E5E5",
chart_label="#616161",
role_user="#005FB8",
role_assistant="#267F99",
role_tool="#6F42C1",
role_result="#317A2D",
role_error="#CD3131",
radius_sm=3,
radius=4,
radius_lg=6,
font_family=_FONT,
font_size=13,
font_mono=_MONO,
)
_PALETTES = {"dark": DARK, "light": LIGHT}
# ---------------------------------------------------------------------------
@@ -368,476 +61,6 @@ _PALETTES = {"dark": DARK, "light": LIGHT}
#
# Read it as a cascade: reset -> shell -> surfaces -> controls -> chrome.
# ---------------------------------------------------------------------------
_TEMPLATE = Template("""
/* ---- reset ------------------------------------------------------------ */
* { font-family: $font_family; font-size: ${font_size}px; }
QWidget { background: $bg; color: $text; }
QMainWindow::separator { background: $border; width: 1px; height: 1px; }
QSplitter::handle { background: $border; }
QSplitter::handle:horizontal { width: 1px; }
QSplitter::handle:vertical { height: 1px; }
QSplitter::handle:hover { background: $border_strong; }
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
QToolTip {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 5px 9px;
}
/* Icons are drawn at text scale, not as decoration. */
QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; }
QTreeWidget#navrail { qproperty-iconSize: 22px 16px; }
/* ---- shell ------------------------------------------------------------ */
QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; }
QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; }
QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; }
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; }
QWidget#contentArea { background: $bg; }
/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the
generic ones are tuned against `bg` and wash out here. The active item also
carries a 2px accent marker, so which section you are in survives even at a
glance or for anyone who cannot separate the two greys. */
QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item {
padding: 6px 4px; border-radius: ${radius}px;
}
QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover {
background: $nav_hover;
}
QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected {
background: $nav_selected; color: $text;
border-left: 2px solid $accent; font-weight: 600;
}
/* Rows the project gate is holding shut: still listed, visibly not open. */
QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; }
/* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it
from the list above so "occasional" reads apart from "everyday". */
QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; }
QScrollArea#navScroll { background: transparent; border: none; }
QScrollArea#navScroll > QWidget > QWidget { background: transparent; }
/* Monitoring ▸ Overview reads as titled sections down one column, the way the
audit page draws it — a quiet caps heading with the content flat underneath,
not six bordered boxes competing with the cards inside them. */
QGroupBox#monSection {
background: transparent; border: none; margin-top: 16px;
padding: 6px 0 0 0; font-weight: 700;
}
QGroupBox#monSection::title {
subcontrol-origin: margin; subcontrol-position: top left; left: 2px; top: 0;
padding: 0; color: $text_faint; font-size: 11px; letter-spacing: 0.5px;
}
/* Segmented control: two-to-four choices shown side by side (language, theme)
instead of a drop-list you must open to see what the options even are. */
QPushButton#segItem {
background: $surface_raised; color: $text_muted; border: 1px solid $border;
padding: 4px 12px; margin: 0; border-radius: 0;
}
QPushButton#segItem:hover { background: $hover; color: $text; }
QPushButton#segItem:checked {
background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600;
}
/* Table of contents down the left of the long dialogs (Settings, Task editor). */
QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; }
QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; }
QListWidget#sectionIndex::item:hover { background: $hover; }
QListWidget#sectionIndex::item:selected {
background: $nav_selected; color: $text; font-weight: 600;
}
/* The strip under the typing box: agent · routing · usage · folder. Reads as
status, not as a second toolbar, so the eye lands on the input first. */
QWidget#composerStatus { border-top: 1px solid $border; background: transparent; }
QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; }
QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox {
background: transparent; border: none; color: $text_muted; font-size: 11px;
padding: 2px 6px; border-radius: ${radius_sm}px;
}
QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover {
background: $hover; color: $text;
}
/* Folder: the current path, written as the screen's title. */
QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; }
/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and
quieter than the app's main tab bars, since they switch a view, not a page. */
QTabBar#viewTabs::tab {
background: transparent; color: $text_muted; border: none;
padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px;
}
QTabBar#viewTabs::tab:hover { background: $hover; color: $text; }
QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; }
/* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so
"which list am I looking at" is answered on screen, not in a tooltip. */
/* The action beside a Co4E section heading ("+ Mới", "Quản lý skill…"). The
wireframe writes these as small accent text; as full buttons they were the
loudest thing in the sidebar and each cost a row of height. */
QPushButton#co4eSectionAction {
background: transparent; border: none; color: $accent;
font-size: 11px; font-weight: 600; padding: 1px 4px;
border-radius: ${radius_sm}px; qproperty-iconSize: 11px 11px;
}
QPushButton#co4eSectionAction:hover { background: $hover; color: $accent; }
QPushButton#co4eSectionAction:pressed { background: $active; }
QPushButton#co4eSectionHdr {
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
background: transparent; border: none; text-align: left; padding: 2px 0;
}
QPushButton#co4eSectionHdr:hover { color: $text; }
/* Account row at the foot of the rail: who you are + the settings that follow
you (provider, language, theme). Separated by a hairline like the group above. */
QWidget#navAccount { border-top: 1px solid $nav_border; }
QWidget#navAccount QComboBox {
background: $surface_raised; border: 1px solid $nav_border; color: $text;
padding: 3px 6px; border-radius: ${radius}px;
}
/* RECENTS section label — quiet, so the thread titles under it read first. */
QLabel#navSectionHdr {
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
padding: 8px 8px 2px 8px; background: transparent;
}
QTreeWidget#navRecents { border-top: 1px solid $nav_border; }
/* Icon library cells. The audit page's note on this screen is that the cells
had no visible edge on hover or selection, so you could not tell what you
were about to pick — "cùng ngôn ngữ thẻ với Agent/Công cụ". */
QListWidget#iconGrid { background: transparent; border: none; }
QListWidget#iconGrid::item {
border: 1px solid transparent; border-radius: ${radius}px;
color: $text_muted; padding: 4px;
}
QListWidget#iconGrid::item:hover {
border: 1px solid $accent; background: $hover; color: $text;
}
QListWidget#iconGrid::item:selected {
border: 1px solid $accent; background: $accent_wash; color: $text;
}
/* Screen title beside its actions, same weight the other admin screens use. */
QLabel#monTitle { font-size: 15px; font-weight: 700; color: $text; }
/* Rail header — the primary action, so it is the one filled button up there. */
QPushButton#navNewChatBtn {
background: $accent_solid; color: #FFFFFF; border: none; font-weight: 600;
padding: 7px 10px; border-radius: ${radius}px; text-align: left;
}
QPushButton#navNewChatBtn:hover { background: $accent_solid_hover; }
QPushButton#navNewChatBtn:disabled { background: $border_strong; color: $text_faint; }
QComboBox#navProjectPick {
background: $surface_raised; border: 1px solid $nav_border; color: $text;
padding: 4px 8px; border-radius: ${radius}px;
}
/* Stand-in for the picker while the rail is 54px wide: icon only, no arrow —
the arrow would eat a third of the width for no information. */
QToolButton#navProjectPickMini {
background: $surface_raised; border: 1px solid $nav_border; border-radius: ${radius}px;
padding: 4px; qproperty-iconSize: 16px 16px;
}
QToolButton#navProjectPickMini:hover { background: $nav_hover; }
QToolButton#navProjectPickMini:disabled { background: transparent; border-color: $border; }
QToolButton#navProjectPickMini::menu-indicator { image: none; width: 0; }
QPushButton#navSettingsBtn {
background: transparent; border: none; color: $text_muted;
/* 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. */
padding: 0; text-align: left; border-radius: ${radius}px;
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
so its icon has to start on their x. A 6px margin put it at 14 — near
enough the middle of the collapsed 54px rail to look centred. */
margin: 2px 0px 6px 0px;
}
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
QPushButton#navSettingsBtn:pressed { background: $active; }
/* ---- surfaces --------------------------------------------------------- */
QGroupBox {
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px;
padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600;
}
QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; }
QScrollArea { background: transparent; border: none; }
QAbstractScrollArea::corner { background: transparent; }
/* ---- tabs: an underline, not a pill. -------------------------------------
The old pill tabs read as buttons and fought the real buttons for
attention. A 2px rule under the active label is quieter and unambiguous. */
QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; }
QTabBar { background: transparent; qproperty-drawBase: 0; }
QTabBar::tab {
background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0;
border: none; border-bottom: 2px solid transparent; font-weight: 500;
}
QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; }
QTabBar::tab:hover:!selected { color: $text; background: $hover; }
/* Co4E flow strip — browser-style tabs, so these stay enclosed. */
QTabBar#flowTabs::tab {
background: $surface; color: $text_muted; border: 1px solid $border;
border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px;
}
QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; }
QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; }
QPushButton#flowAddBtn {
background: transparent; color: $text_muted; border: 1px solid $border;
border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px;
}
QPushButton#flowAddBtn:hover { background: $hover; color: $text; }
/* Co4E icon sidebar — no chrome until it is the active one. */
QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; }
QTabBar#co4eSideTabs::tab {
background: transparent; color: $text_muted; border: none;
border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0;
}
QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; }
QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; }
QGraphicsView#co4eCanvas {
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
}
/* ---- text entry & item views ------------------------------------------ */
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox,
QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget {
background: $surface_raised; color: $text; border: 1px solid $border;
border-radius: ${radius}px; selection-background-color: $selection_bg;
selection-color: $selection_fg; outline: 0;
}
QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; }
QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; }
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; }
QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled {
background: $surface; color: $text_disabled;
}
/* Explicit up/down button geometry: once ANY spin-box subcontrol is styled,
Qt uses exactly this rect for both painting AND hit-testing, so the
clickable area can no longer drift from what's drawn (the previous
unstyled default arrows misaligned their own click region at 125%/150%
Windows display scaling — this pins both to the same rect instead). */
QSpinBox::up-button, QDoubleSpinBox::up-button {
subcontrol-origin: border; subcontrol-position: top right;
width: 18px; height: 15px; border: none; background: transparent;
}
QSpinBox::down-button, QDoubleSpinBox::down-button {
subcontrol-origin: border; subcontrol-position: bottom right;
width: 18px; height: 15px; border: none; background: transparent;
}
QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background: $hover; }
QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed,
QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { background: $active; }
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url($chevron_up); width: 9px; height: 9px; }
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url($chevron_down); width: 9px; height: 9px; }
QSpinBox::up-arrow:disabled, QSpinBox::down-arrow:disabled,
QDoubleSpinBox::up-arrow:disabled, QDoubleSpinBox::down-arrow:disabled { image: none; }
QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; }
QTreeView::item:hover, QListView::item:hover { background: $hover; }
QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {
background: $accent_soft; color: $text;
}
/* The platform style draws its own dotted/solid focus rect on the current
cell on top of the selection tint above — visible as a stray light border
on a click. The selection tint already marks "current row"; drop the rect. */
QTreeView::item:focus, QListView::item:focus, QTableView::item:focus { outline: none; border: none; }
/* Kanban lanes (Schedule Task): seven lanes share the board width, so a card's
own inset competes with the other six for space the same way the inter-lane
gap did — trimmed to match. */
QListWidget#kanbanLane::item { padding: 3px 2px; }
QHeaderView::section {
background: $bg; color: $text_muted; border: none;
border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600;
}
/* ---- buttons -----------------------------------------------------------
Default is a quiet outline. Weight is reserved for #primary / #danger, so
at most one button per view should carry a fill. */
QPushButton {
background: $surface_raised; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 7px 14px; font-weight: 500;
}
QPushButton:hover { background: $hover; border-color: $border_strong; }
QPushButton:pressed { background: $active; }
QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; }
QPushButton:focus { border: 1px solid $focus_ring; }
QPushButton#primary {
background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
}
QPushButton#primary:hover { background: $accent_solid_hover; }
QPushButton#primary:pressed { background: $accent_solid_active; }
QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; }
QPushButton#danger {
background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
}
QPushButton#danger:hover { background: $danger_solid_hover; }
QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; }
/* Ghost buttons: nav section headers and icon-only chrome. */
QPushButton#navMenuBtn {
background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px;
font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left;
}
QPushButton#navMenuBtn:hover { background: $hover; color: $text; }
QPushButton#navMenuBtn:pressed { background: $active; }
QToolButton {
background: transparent; color: $text_muted; border: none;
border-radius: ${radius}px; padding: 5px;
}
QToolButton:hover { background: $hover; color: $text; }
QToolButton:pressed { background: $active; }
QToolButton::menu-indicator { image: none; }
/* ---- pickers ----------------------------------------------------------- */
QComboBox {
background: $surface_raised; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 6px 10px;
}
QComboBox:hover { background: $hover; }
QComboBox:focus { border-color: $focus_ring; }
QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; }
QComboBox::drop-down { border: none; width: 20px; }
QComboBox::down-arrow { image: url($chevron_down); width: 10px; height: 10px; }
QComboBox::down-arrow:disabled { image: none; }
QComboBox QAbstractItemView {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 4px; outline: none;
selection-background-color: $accent_soft; selection-color: $text;
}
QMenu { background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 4px; }
QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; }
QMenu::item:selected { background: $accent_soft; color: $text; }
QMenu::item:disabled { color: $text_disabled; }
QMenu::separator { height: 1px; background: $border; margin: 4px 6px; }
QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; }
QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; }
QMenuBar::item:selected { background: $hover; }
/* ---- toggles ----------------------------------------------------------- */
QCheckBox, QRadioButton { spacing: 8px; background: transparent; }
QCheckBox::indicator, QRadioButton::indicator {
width: 16px; height: 16px; background: $surface_raised;
border: 1px solid $border_strong; border-radius: ${radius_sm}px;
}
QRadioButton::indicator { border-radius: 9px; }
QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; }
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
background: $accent_solid; border-color: $accent_solid;
}
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
background: $surface; border-color: $border;
}
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {
background: $border_strong; border-color: $border_strong;
}
QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; }
QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; }
QSlider::handle:horizontal {
width: 14px; height: 14px; margin: -6px 0; border-radius: 7px;
background: $surface_raised; border: 1px solid $border_strong;
}
QSlider::handle:horizontal:hover { border-color: $accent; }
QProgressBar {
background: $surface; border: none; border-radius: 3px;
height: 6px; text-align: center; color: $text_muted;
}
QProgressBar::chunk { background: $accent_solid; border-radius: 3px; }
/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; }
QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; }
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; }
QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; }
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; }
QScrollBar::add-page, QScrollBar::sub-page { background: none; }
/* ---- badges & inline text tones ---------------------------------------
One shape, seven tones. Pick by meaning: badgeSuccess for a finished run,
badgeDanger for a failed one — not by which colour looks nice. badgeNeutral
is the odd one out: it deliberately uses OPAQUE tokens ($active/$text_muted,
not an rgba() *_soft one) since it renders inside table cells that can sit
over a selection tint — an rgba() background there would composite
differently selected vs not (see Monitoring ▸ Sự kiện bảo mật's Hành động
pill, which hit exactly this). */
QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger,
QLabel#badgePurple, QLabel#badgePink, QLabel#badgeNeutral {
border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600;
}
QLabel#badge { background: $info_soft; color: $info; }
QLabel#badgeSuccess { background: $success_soft; color: $success; }
QLabel#badgeWarn { background: $warning_soft; color: $warning; }
QLabel#badgeDanger { background: $danger_soft; color: $danger; }
QLabel#badgePurple { background: $purple_soft; color: $purple; }
QLabel#badgePink { background: $pink_soft; color: $pink; }
QLabel#badgeNeutral { background: $active; color: $text_muted; }
/* ---- event-detail panel (Monitoring ▸ Sự kiện bảo mật) -----------------
A neutral, low-emphasis tag — the "Loại" chip: a category label with no
colour coding of its own (colour is reserved for the Trạng thái badge
beside it). */
QLabel#neutralTag {
background: $surface_raised; border: 1px solid $border; border-radius: ${radius_sm}px;
padding: 2px 8px; font-size: 12px;
}
/* A short identifier shown as a bordered monospace chip (machine name,
event id). */
QLabel#monoChip {
font-family: "Cascadia Code", Consolas, monospace; background: $surface_raised;
border: 1px solid $border; border-radius: ${radius_sm}px; padding: 1px 6px; font-size: 12px;
}
/* Section caption inside the panel — the same quiet caps heading as
Monitoring ▸ Overview's group titles (monSection::title above), with a
hairline under it since the panel has no group-box border of its own. */
QLabel#detailSectionHdr {
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
padding-bottom: 4px; margin-top: 6px; border-bottom: 1px solid $border;
}
/* The blocked-detail text renders as a fixed dark "terminal" block — the
same look in both themes, like a code snippet, so it reads consistently
against whichever tint the row around it happens to carry. */
QWidget#detailCodeBlock { background: #1B1A19; border-radius: ${radius}px; }
QLabel#detailCodeText {
color: #CCFF00; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px;
}
QPushButton#detailCopyBtn {
background: rgba(255,255,255,0.15); color: #FFFFFF; border: none;
border-radius: ${radius_sm}px; padding: 3px 10px; font-size: 11px;
}
QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); }
QLabel { background: transparent; }
QLabel#hint { color: $text_muted; }
QLabel#faint { color: $text_faint; }
QLabel#warning { color: $warning; font-weight: 600; }
QLabel#error { color: $danger; font-weight: 600; }
QLabel#success { color: $success; font-weight: 600; }
QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; }
/* ---- code, terminals & logs -------------------------------------------
These read as "sunken" surfaces: the eye goes in, not across. */
QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView {
background: $code_bg; color: $code_fg; border: none;
font-family: $font_mono; selection-background-color: $code_selection;
}
QLineEdit#termInput {
background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border;
font-family: $font_mono; border-radius: 0; padding: 7px 10px;
}
QLineEdit#termInput:focus { border-top-color: $accent; }
/* The help-agent dock styles itself from these same tokens — it is a floating
overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */
""")
def resolve_theme(theme: str) -> str:
+328
View File
@@ -0,0 +1,328 @@
"""Hai bảng màu Tối và Sáng, cùng lớp ``Palette`` — dữ liệu, không logic.
Tách khỏi ``theme.py``: đây là chỗ duy nhất cần mở khi đổi màu. Mọi thứ khác
trong theme chỉ đọc từ đây.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from string import Template
def _chevron_asset(direction: str, color: str) -> str:
"""Render (and cache on disk) a small chevron PNG for QComboBox/QSpinBox
arrow subcontrols. QSS's ``image:`` property only accepts a resource or
file path, never a live QPixmap — and once ``::drop-down``/``::up-button``/
``::down-button`` are styled at all, Qt stops drawing its own built-in
arrow, so without this the controls show no affordance whatsoever."""
import hashlib
import tempfile
from pathlib import Path
key = hashlib.md5(f"{direction}-{color}".encode()).hexdigest()[:10]
path = Path(tempfile.gettempdir()) / "cowork_local_theme" / f"chevron_{key}.png"
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
from PySide6.QtCore import QPointF, Qt
from PySide6.QtGui import QColor, QPainter, QPixmap
size = 12
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
pen = p.pen()
pen.setColor(QColor(color))
pen.setWidthF(1.6)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
p.setPen(pen)
pts = ([QPointF(2.5, 4.5), QPointF(6, 8), QPointF(9.5, 4.5)] if direction == "down"
else [QPointF(2.5, 7.5), QPointF(6, 4), QPointF(9.5, 7.5)])
p.drawPolyline(pts)
p.end()
pm.save(str(path))
return path.as_posix()
@dataclass(frozen=True)
class Palette:
"""Every colour and shape value the interface is allowed to use."""
name: str
# --- surfaces: a 4-step ramp from the window back to the frontmost layer.
bg: str # window / canvas backdrop
surface: str # panels, cards, group boxes (NOT the nav rail)
surface_raised: str # inputs, lists, trees — things you type or pick in
overlay: str # menus, tooltips, popups (floats above everything)
sunken: str # logs, code, terminals — things you read into
hover: str # hover wash on rows, tabs, ghost buttons
active: str # pressed / held state
# The nav rail gets its own step rather than borrowing `surface`. It is a
# permanent region of the window, not a card floating on the page.
#
# Following VS Code, the rail is *darker* than the content area (dark) or a
# shade off white (light). The step is small on purpose — VS Code separates
# the rail with a border, not a big tonal jump — so `nav_border` is doing
# real work here and must stay visible.
nav_bg: str
nav_border: str
nav_hover: str
nav_selected: str
# --- lines
border: str # default hairline
border_strong: str # hairline that must survive next to a filled surface
focus_ring: str # keyboard/typing focus
# --- text
text: str
text_muted: str # secondary copy, captions, group-box titles
text_faint: str # metadata, timestamps, placeholder
text_disabled: str
on_accent: str # text drawn on top of a filled accent/status surface
# --- accent: `accent` tints text & icons, `accent_solid` fills buttons.
accent: str
accent_solid: str
accent_solid_hover: str
accent_solid_active: str
accent_soft: str # translucent wash for selected rows (QSS only)
accent_soft_hover: str
accent_wash: str # the same tint pre-blended to a solid, for Qt rich
# text (bgcolor=, <table>) where alpha is ignored
# --- status
success: str
success_soft: str
warning: str
warning_soft: str
danger: str
danger_solid: str
danger_solid_hover: str
danger_soft: str
info: str
info_soft: str
purple: str
purple_soft: str
pink: str
pink_soft: str
# --- selection (text selection inside editors and inputs)
selection_bg: str
selection_fg: str
# --- scrollbars
scroll_handle: str
scroll_handle_hover: str
# --- code & terminal
code_bg: str
code_fg: str
code_gutter_bg: str
code_gutter_fg: str
code_selection: str
code_comment: str
code_keyword: str
code_type: str
code_func: str
code_attr: str
code_string: str
code_number: str
code_error: str
# --- diff / inline change badges
diff_add_bg: str
diff_add_fg: str
diff_del_bg: str
diff_del_fg: str
# --- charts
chart_grid: str
chart_label: str
# --- conversation & graph node roles
role_user: str
role_assistant: str
role_tool: str
role_result: str
role_error: str
# --- shape & type
radius_sm: int
radius: int
radius_lg: int
font_family: str
font_size: int
font_mono: str
_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif'
_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace'
DARK = Palette(
name="dark",
# ---- VS Code "Dark Modern" ----------------------------------------------
# Values taken from the shipped theme JSON. Where VS Code's own choice falls
# below WCAG AA it is nudged just far enough to pass; each such value carries
# a note with VS Code's original and the measured ratio.
bg="#1F1F1F", # editor.background
surface="#252526", # panel / card
surface_raised="#313131", # input.background
overlay="#252526", # menus, tooltips
sunken="#181818", # logs, terminals — below the ramp
hover="#2A2D2E", # list.hoverBackground
active="#37373D", # list.inactiveSelectionBackground
# The sidebar is DARKER than the editor — that is the VS Code silhouette.
nav_bg="#181818", # sideBar.background
nav_border="#2B2B2B", # sideBar.border
nav_hover="#2A2D2E",
nav_selected="#04395E", # list.activeSelectionBackground
border="#2B2B2B", # panel.border
border_strong="#3C3C3C", # input.border
focus_ring="#0078D4", # focusBorder
text="#CCCCCC", # editor.foreground
text_muted="#9D9D9D", # descriptionForeground
text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces
text_disabled="#5A5A5A",
on_accent="#FFFFFF",
accent="#4DAAFC", # textLink.foreground — accent as TEXT
accent_solid="#0078D4", # button.background — accent as FILL
accent_solid_hover="#026EC1",
accent_solid_active="#005FB8",
accent_soft="rgba(0,120,212,0.22)",
accent_soft_hover="rgba(0,120,212,0.32)",
accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha
success="#89D185", # gitDecoration added
success_soft="rgba(137,209,133,0.16)",
warning="#CCA700", # editorWarning
warning_soft="rgba(204,167,0,0.16)",
danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels)
danger_solid="#C4302B",
danger_solid_hover="#D9433C",
danger_soft="rgba(241,76,76,0.16)",
info="#4DAAFC",
info_soft="rgba(77,170,252,0.16)",
purple="#C586C0", # Dark+ syntax purple
purple_soft="rgba(197,134,192,0.16)",
pink="#D16D9E",
pink_soft="rgba(209,109,158,0.16)",
selection_bg="#264F78", # editor.selectionBackground
selection_fg="#FFFFFF",
scroll_handle="#4E4E4E", # scrollbarSlider
scroll_handle_hover="#5A5A5A",
code_bg="#1F1F1F",
code_fg="#CCCCCC",
code_gutter_bg="#1F1F1F",
# VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA.
code_gutter_fg="#858D97",
code_selection="#264F78",
code_comment="#6A9955", # ---- Dark+ syntax, unchanged --------------
code_keyword="#569CD6",
code_type="#4EC9B0",
code_func="#DCDCAA",
code_attr="#9CDCFE",
code_string="#CE9178",
code_number="#B5CEA8",
code_error="#F44747",
diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended
diff_add_fg="#89D185",
diff_del_bg="#4B1818", # diffEditor removed, pre-blended
diff_del_fg="#F76464",
chart_grid="#2B2B2B",
chart_label="#9D9D9D",
role_user="#4DAAFC",
role_assistant="#4EC9B0",
role_tool="#C586C0",
role_result="#89D185",
role_error="#F14C4C",
radius_sm=3, # VS Code is squarer than the previous look
radius=4,
radius_lg=6,
font_family=_FONT,
font_size=13,
font_mono=_MONO,
)
LIGHT = Palette(
name="light",
# ---- VS Code "Light Modern" ---------------------------------------------
bg="#FFFFFF", # editor.background
surface="#F8F8F8", # sideBar / panel
surface_raised="#FFFFFF", # input.background
overlay="#FFFFFF",
sunken="#F3F3F3",
hover="#F2F2F2", # list.hoverBackground
active="#E8E8E8", # list.activeSelectionBackground
nav_bg="#F8F8F8", # sideBar.background
nav_border="#E5E5E5", # sideBar.border
nav_hover="#F2F2F2",
nav_selected="#E4E6F1", # active row, tinted toward the accent
border="#E5E5E5",
border_strong="#CECECE", # input.border
focus_ring="#005FB8", # focusBorder
text="#3B3B3B", # editor.foreground
text_muted="#616161",
# VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there.
text_faint="#6E6E6E",
text_disabled="#A0A0A0",
on_accent="#FFFFFF",
accent="#005FB8", # textLink / button
accent_solid="#005FB8",
accent_solid_hover="#0258A8",
accent_solid_active="#004C97",
accent_soft="rgba(0,95,184,0.10)",
accent_soft_hover="rgba(0,95,184,0.16)",
accent_wash="#E6EEF8",
# VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted.
success="#317A2D",
success_soft="#DFF3DE",
warning="#8F6500", # VS Code #BF8803 = 3.12:1
warning_soft="#FBF0D0",
danger="#CD3131", # editorError
danger_solid="#CD3131",
danger_solid_hover="#B82A2A",
danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it
info="#005FB8",
info_soft="#DDEBF9",
purple="#6F42C1",
purple_soft="#EDE7FA",
pink="#B3247E",
pink_soft="#FAE3F0",
selection_bg="#ADD6FF", # editor.selectionBackground
selection_fg="#000000",
scroll_handle="#C1C1C1",
scroll_handle_hover="#A6A6A6",
code_bg="#FFFFFF",
code_fg="#3B3B3B",
code_gutter_bg="#F8F8F8",
code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter
code_selection="#ADD6FF",
code_comment="#008000", # ---- Light+ syntax ------------------------
code_keyword="#0000FF",
code_type="#267F99",
code_func="#795E26",
code_attr="#E50000",
code_string="#A31515",
code_number="#098658",
code_error="#CD3131",
diff_add_bg="#DBF4DB",
diff_add_fg="#1E6F1A",
diff_del_bg="#FBE3E3",
diff_del_fg="#B82A2A",
chart_grid="#E5E5E5",
chart_label="#616161",
role_user="#005FB8",
role_assistant="#267F99",
role_tool="#6F42C1",
role_result="#317A2D",
role_error="#CD3131",
radius_sm=3,
radius=4,
radius_lg=6,
font_family=_FONT,
font_size=13,
font_mono=_MONO,
)
_PALETTES = {"dark": DARK, "light": LIGHT}
+198
View File
@@ -0,0 +1,198 @@
"""Khuôn QSS của toàn ứng dụng — 470 dòng bảng kiểu.
Tách khỏi ``theme.py`` vì nó là **dữ liệu**, không phải logic: một chuỗi
``string.Template`` mà ``stylesheet()`` thay biến vào. Để chung thì mỗi lần
muốn sửa một hàm nhỏ trong theme.py lại phải cuộn qua 470 dòng CSS.
Sửa màu thì sang ``theme_palettes.py``; ở đây chỉ sửa hình dạng và khoảng cách.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from string import Template
from .theme_qss_controls import QSS_CONTROLS
_QSS_SHELL = """
/* ---- reset ------------------------------------------------------------ */
* { font-family: $font_family; font-size: ${font_size}px; }
QWidget { background: $bg; color: $text; }
QMainWindow::separator { background: $border; width: 1px; height: 1px; }
QSplitter::handle { background: $border; }
QSplitter::handle:horizontal { width: 1px; }
QSplitter::handle:vertical { height: 1px; }
QSplitter::handle:hover { background: $border_strong; }
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
QToolTip {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 5px 9px;
}
/* Icons are drawn at text scale, not as decoration. */
QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; }
QTreeWidget#navrail { qproperty-iconSize: 22px 16px; }
/* ---- shell ------------------------------------------------------------ */
QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; }
QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; }
QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; }
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; }
QWidget#contentArea { background: $bg; }
/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the
generic ones are tuned against `bg` and wash out here. The active item also
carries a 2px accent marker, so which section you are in survives even at a
glance or for anyone who cannot separate the two greys. */
QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item {
padding: 6px 4px; border-radius: ${radius}px;
}
QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover {
background: $nav_hover;
}
QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected {
background: $nav_selected; color: $text;
border-left: 2px solid $accent; font-weight: 600;
}
/* Rows the project gate is holding shut: still listed, visibly not open. */
QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; }
/* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it
from the list above so "occasional" reads apart from "everyday". */
QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; }
QScrollArea#navScroll { background: transparent; border: none; }
QScrollArea#navScroll > QWidget > QWidget { background: transparent; }
/* Monitoring ▸ Overview reads as titled sections down one column, the way the
audit page draws it — a quiet caps heading with the content flat underneath,
not six bordered boxes competing with the cards inside them. */
QGroupBox#monSection {
background: transparent; border: none; margin-top: 16px;
padding: 6px 0 0 0; font-weight: 700;
}
QGroupBox#monSection::title {
subcontrol-origin: margin; subcontrol-position: top left; left: 2px; top: 0;
padding: 0; color: $text_faint; font-size: 11px; letter-spacing: 0.5px;
}
/* Segmented control: two-to-four choices shown side by side (language, theme)
instead of a drop-list you must open to see what the options even are. */
QPushButton#segItem {
background: $surface_raised; color: $text_muted; border: 1px solid $border;
padding: 4px 12px; margin: 0; border-radius: 0;
}
QPushButton#segItem:hover { background: $hover; color: $text; }
QPushButton#segItem:checked {
background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600;
}
/* Table of contents down the left of the long dialogs (Settings, Task editor). */
QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; }
QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; }
QListWidget#sectionIndex::item:hover { background: $hover; }
QListWidget#sectionIndex::item:selected {
background: $nav_selected; color: $text; font-weight: 600;
}
/* The strip under the typing box: agent · routing · usage · folder. Reads as
status, not as a second toolbar, so the eye lands on the input first. */
QWidget#composerStatus { border-top: 1px solid $border; background: transparent; }
QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; }
QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox {
background: transparent; border: none; color: $text_muted; font-size: 11px;
padding: 2px 6px; border-radius: ${radius_sm}px;
}
QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover {
background: $hover; color: $text;
}
/* Folder: the current path, written as the screen's title. */
QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; }
/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and
quieter than the app's main tab bars, since they switch a view, not a page. */
QTabBar#viewTabs::tab {
background: transparent; color: $text_muted; border: none;
padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px;
}
QTabBar#viewTabs::tab:hover { background: $hover; color: $text; }
QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; }
/* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so
"which list am I looking at" is answered on screen, not in a tooltip. */
/* The action beside a Co4E section heading ("+ Mới", "Quản lý skill…"). The
wireframe writes these as small accent text; as full buttons they were the
loudest thing in the sidebar and each cost a row of height. */
QPushButton#co4eSectionAction {
background: transparent; border: none; color: $accent;
font-size: 11px; font-weight: 600; padding: 1px 4px;
border-radius: ${radius_sm}px; qproperty-iconSize: 11px 11px;
}
QPushButton#co4eSectionAction:hover { background: $hover; color: $accent; }
QPushButton#co4eSectionAction:pressed { background: $active; }
QPushButton#co4eSectionHdr {
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
background: transparent; border: none; text-align: left; padding: 2px 0;
}
QPushButton#co4eSectionHdr:hover { color: $text; }
/* Account row at the foot of the rail: who you are + the settings that follow
you (provider, language, theme). Separated by a hairline like the group above. */
QWidget#navAccount { border-top: 1px solid $nav_border; }
QWidget#navAccount QComboBox {
background: $surface_raised; border: 1px solid $nav_border; color: $text;
padding: 3px 6px; border-radius: ${radius}px;
}
/* RECENTS section label — quiet, so the thread titles under it read first. */
QLabel#navSectionHdr {
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
padding: 8px 8px 2px 8px; background: transparent;
}
QTreeWidget#navRecents { border-top: 1px solid $nav_border; }
/* Icon library cells. The audit page's note on this screen is that the cells
had no visible edge on hover or selection, so you could not tell what you
were about to pick — "cùng ngôn ngữ thẻ với Agent/Công cụ". */
QListWidget#iconGrid { background: transparent; border: none; }
QListWidget#iconGrid::item {
border: 1px solid transparent; border-radius: ${radius}px;
color: $text_muted; padding: 4px;
}
QListWidget#iconGrid::item:hover {
border: 1px solid $accent; background: $hover; color: $text;
}
QListWidget#iconGrid::item:selected {
border: 1px solid $accent; background: $accent_wash; color: $text;
}
/* Screen title beside its actions, same weight the other admin screens use. */
QLabel#monTitle { font-size: 15px; font-weight: 700; color: $text; }
/* Rail header — the primary action, so it is the one filled button up there. */
QPushButton#navNewChatBtn {
background: $accent_solid; color: #FFFFFF; border: none; font-weight: 600;
padding: 7px 10px; border-radius: ${radius}px; text-align: left;
}
QPushButton#navNewChatBtn:hover { background: $accent_solid_hover; }
QPushButton#navNewChatBtn:disabled { background: $border_strong; color: $text_faint; }
QComboBox#navProjectPick {
background: $surface_raised; border: 1px solid $nav_border; color: $text;
padding: 4px 8px; border-radius: ${radius}px;
}
/* Stand-in for the picker while the rail is 54px wide: icon only, no arrow —
the arrow would eat a third of the width for no information. */
QToolButton#navProjectPickMini {
background: $surface_raised; border: 1px solid $nav_border; border-radius: ${radius}px;
padding: 4px; qproperty-iconSize: 16px 16px;
}
QToolButton#navProjectPickMini:hover { background: $nav_hover; }
QToolButton#navProjectPickMini:disabled { background: transparent; border-color: $border; }
QToolButton#navProjectPickMini::menu-indicator { image: none; width: 0; }
QPushButton#navSettingsBtn {
background: transparent; border: none; color: $text_muted;
/* 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. */
padding: 0; text-align: left; border-radius: ${radius}px;
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
so its icon has to start on their x. A 6px margin put it at 14 — near
enough the middle of the collapsed 54px rail to look centred. */
margin: 2px 0px 6px 0px;
}
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
QPushButton#navSettingsBtn:pressed { background: $active; }
"""
#: Hai nửa nối lại. Cắt đôi vì một chuỗi 470 dòng vượt ngưỡng 400 dòng/file.
_TEMPLATE = Template(_QSS_SHELL + QSS_CONTROLS)
+306
View File
@@ -0,0 +1,306 @@
"""Nửa sau của khuôn QSS: bề mặt, tab, ô nhập, nút, badge, log.
Cắt đôi khuôn QSS đúng mạch của chính nó: ``theme_qss.py`` giữ phần vỏ
(reset + shell: thanh rail, khung chính), file này giữ phần điều khiển.
Hai nửa được nối lại trong ``theme_qss.py``.
Sửa màu thì sang ``theme_palettes.py``; ở đây chỉ sửa hình dạng và khoảng cách.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from string import Template
QSS_CONTROLS = """/* ---- surfaces --------------------------------------------------------- */
QGroupBox {
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px;
padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600;
}
QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; }
QScrollArea { background: transparent; border: none; }
QAbstractScrollArea::corner { background: transparent; }
/* ---- tabs: an underline, not a pill. -------------------------------------
The old pill tabs read as buttons and fought the real buttons for
attention. A 2px rule under the active label is quieter and unambiguous. */
QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; }
QTabBar { background: transparent; qproperty-drawBase: 0; }
QTabBar::tab {
background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0;
border: none; border-bottom: 2px solid transparent; font-weight: 500;
}
QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; }
QTabBar::tab:hover:!selected { color: $text; background: $hover; }
/* Co4E flow strip — browser-style tabs, so these stay enclosed. */
QTabBar#flowTabs::tab {
background: $surface; color: $text_muted; border: 1px solid $border;
border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px;
}
QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; }
QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; }
QPushButton#flowAddBtn {
background: transparent; color: $text_muted; border: 1px solid $border;
border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px;
}
QPushButton#flowAddBtn:hover { background: $hover; color: $text; }
/* Co4E icon sidebar — no chrome until it is the active one. */
QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; }
QTabBar#co4eSideTabs::tab {
background: transparent; color: $text_muted; border: none;
border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0;
}
QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; }
QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; }
QGraphicsView#co4eCanvas {
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
}
/* ---- text entry & item views ------------------------------------------ */
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox,
QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget {
background: $surface_raised; color: $text; border: 1px solid $border;
border-radius: ${radius}px; selection-background-color: $selection_bg;
selection-color: $selection_fg; outline: 0;
}
QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; }
QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; }
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; }
QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled {
background: $surface; color: $text_disabled;
}
/* Explicit up/down button geometry: once ANY spin-box subcontrol is styled,
Qt uses exactly this rect for both painting AND hit-testing, so the
clickable area can no longer drift from what's drawn (the previous
unstyled default arrows misaligned their own click region at 125%/150%
Windows display scaling — this pins both to the same rect instead). */
QSpinBox::up-button, QDoubleSpinBox::up-button {
subcontrol-origin: border; subcontrol-position: top right;
width: 18px; height: 15px; border: none; background: transparent;
}
QSpinBox::down-button, QDoubleSpinBox::down-button {
subcontrol-origin: border; subcontrol-position: bottom right;
width: 18px; height: 15px; border: none; background: transparent;
}
QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background: $hover; }
QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed,
QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { background: $active; }
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url($chevron_up); width: 9px; height: 9px; }
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url($chevron_down); width: 9px; height: 9px; }
QSpinBox::up-arrow:disabled, QSpinBox::down-arrow:disabled,
QDoubleSpinBox::up-arrow:disabled, QDoubleSpinBox::down-arrow:disabled { image: none; }
QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; }
QTreeView::item:hover, QListView::item:hover { background: $hover; }
QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {
background: $accent_soft; color: $text;
}
/* The platform style draws its own dotted/solid focus rect on the current
cell on top of the selection tint above — visible as a stray light border
on a click. The selection tint already marks "current row"; drop the rect. */
QTreeView::item:focus, QListView::item:focus, QTableView::item:focus { outline: none; border: none; }
/* Kanban lanes (Schedule Task): seven lanes share the board width, so a card's
own inset competes with the other six for space the same way the inter-lane
gap did — trimmed to match. */
QListWidget#kanbanLane::item { padding: 3px 2px; }
QHeaderView::section {
background: $bg; color: $text_muted; border: none;
border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600;
}
/* ---- buttons -----------------------------------------------------------
Default is a quiet outline. Weight is reserved for #primary / #danger, so
at most one button per view should carry a fill. */
QPushButton {
background: $surface_raised; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 7px 14px; font-weight: 500;
}
QPushButton:hover { background: $hover; border-color: $border_strong; }
QPushButton:pressed { background: $active; }
QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; }
QPushButton:focus { border: 1px solid $focus_ring; }
QPushButton#primary {
background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
}
QPushButton#primary:hover { background: $accent_solid_hover; }
QPushButton#primary:pressed { background: $accent_solid_active; }
QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; }
QPushButton#danger {
background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
}
QPushButton#danger:hover { background: $danger_solid_hover; }
QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; }
/* Ghost buttons: nav section headers and icon-only chrome. */
QPushButton#navMenuBtn {
background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px;
font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left;
}
QPushButton#navMenuBtn:hover { background: $hover; color: $text; }
QPushButton#navMenuBtn:pressed { background: $active; }
QToolButton {
background: transparent; color: $text_muted; border: none;
border-radius: ${radius}px; padding: 5px;
}
QToolButton:hover { background: $hover; color: $text; }
QToolButton:pressed { background: $active; }
QToolButton::menu-indicator { image: none; }
/* ---- pickers ----------------------------------------------------------- */
QComboBox {
background: $surface_raised; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 6px 10px;
}
QComboBox:hover { background: $hover; }
QComboBox:focus { border-color: $focus_ring; }
QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; }
QComboBox::drop-down { border: none; width: 20px; }
QComboBox::down-arrow { image: url($chevron_down); width: 10px; height: 10px; }
QComboBox::down-arrow:disabled { image: none; }
QComboBox QAbstractItemView {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 4px; outline: none;
selection-background-color: $accent_soft; selection-color: $text;
}
QMenu { background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 4px; }
QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; }
QMenu::item:selected { background: $accent_soft; color: $text; }
QMenu::item:disabled { color: $text_disabled; }
QMenu::separator { height: 1px; background: $border; margin: 4px 6px; }
QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; }
QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; }
QMenuBar::item:selected { background: $hover; }
/* ---- toggles ----------------------------------------------------------- */
QCheckBox, QRadioButton { spacing: 8px; background: transparent; }
QCheckBox::indicator, QRadioButton::indicator {
width: 16px; height: 16px; background: $surface_raised;
border: 1px solid $border_strong; border-radius: ${radius_sm}px;
}
QRadioButton::indicator { border-radius: 9px; }
QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; }
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
background: $accent_solid; border-color: $accent_solid;
}
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
background: $surface; border-color: $border;
}
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {
background: $border_strong; border-color: $border_strong;
}
QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; }
QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; }
QSlider::handle:horizontal {
width: 14px; height: 14px; margin: -6px 0; border-radius: 7px;
background: $surface_raised; border: 1px solid $border_strong;
}
QSlider::handle:horizontal:hover { border-color: $accent; }
QProgressBar {
background: $surface; border: none; border-radius: 3px;
height: 6px; text-align: center; color: $text_muted;
}
QProgressBar::chunk { background: $accent_solid; border-radius: 3px; }
/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; }
QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; }
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; }
QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; }
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; }
QScrollBar::add-page, QScrollBar::sub-page { background: none; }
/* ---- badges & inline text tones ---------------------------------------
One shape, seven tones. Pick by meaning: badgeSuccess for a finished run,
badgeDanger for a failed one — not by which colour looks nice. badgeNeutral
is the odd one out: it deliberately uses OPAQUE tokens ($active/$text_muted,
not an rgba() *_soft one) since it renders inside table cells that can sit
over a selection tint — an rgba() background there would composite
differently selected vs not (see Monitoring ▸ Sự kiện bảo mật's Hành động
pill, which hit exactly this). */
QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger,
QLabel#badgePurple, QLabel#badgePink, QLabel#badgeNeutral {
border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600;
}
QLabel#badge { background: $info_soft; color: $info; }
QLabel#badgeSuccess { background: $success_soft; color: $success; }
QLabel#badgeWarn { background: $warning_soft; color: $warning; }
QLabel#badgeDanger { background: $danger_soft; color: $danger; }
QLabel#badgePurple { background: $purple_soft; color: $purple; }
QLabel#badgePink { background: $pink_soft; color: $pink; }
QLabel#badgeNeutral { background: $active; color: $text_muted; }
/* ---- event-detail panel (Monitoring ▸ Sự kiện bảo mật) -----------------
A neutral, low-emphasis tag — the "Loại" chip: a category label with no
colour coding of its own (colour is reserved for the Trạng thái badge
beside it). */
QLabel#neutralTag {
background: $surface_raised; border: 1px solid $border; border-radius: ${radius_sm}px;
padding: 2px 8px; font-size: 12px;
}
/* A short identifier shown as a bordered monospace chip (machine name,
event id). */
QLabel#monoChip {
font-family: "Cascadia Code", Consolas, monospace; background: $surface_raised;
border: 1px solid $border; border-radius: ${radius_sm}px; padding: 1px 6px; font-size: 12px;
}
/* Section caption inside the panel — the same quiet caps heading as
Monitoring ▸ Overview's group titles (monSection::title above), with a
hairline under it since the panel has no group-box border of its own. */
QLabel#detailSectionHdr {
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
padding-bottom: 4px; margin-top: 6px; border-bottom: 1px solid $border;
}
/* The blocked-detail text renders as a fixed dark "terminal" block — the
same look in both themes, like a code snippet, so it reads consistently
against whichever tint the row around it happens to carry. */
QWidget#detailCodeBlock { background: #1B1A19; border-radius: ${radius}px; }
QLabel#detailCodeText {
color: #CCFF00; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px;
}
QPushButton#detailCopyBtn {
background: rgba(255,255,255,0.15); color: #FFFFFF; border: none;
border-radius: ${radius_sm}px; padding: 3px 10px; font-size: 11px;
}
QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); }
QLabel { background: transparent; }
QLabel#hint { color: $text_muted; }
QLabel#faint { color: $text_faint; }
QLabel#warning { color: $warning; font-weight: 600; }
QLabel#error { color: $danger; font-weight: 600; }
QLabel#success { color: $success; font-weight: 600; }
QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; }
/* ---- code, terminals & logs -------------------------------------------
These read as "sunken" surfaces: the eye goes in, not across. */
QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView {
background: $code_bg; color: $code_fg; border: none;
font-family: $font_mono; selection-background-color: $code_selection;
}
QLineEdit#termInput {
background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border;
font-family: $font_mono; border-radius: 0; padding: 7px 10px;
}
QLineEdit#termInput:focus { border-top-color: $accent; }
/* The help-agent dock styles itself from these same tokens — it is a floating
overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */
"""
+136 -132
View File
@@ -1,132 +1,136 @@
"""Round 5: do the checks actually bite?
Rounds 1–4 all report green. That is only worth something if the checks would
have turned red had the work not been done. So this round breaks the app on
purpose, one feature at a time, and fails if the corresponding check still
passes — a check that cannot fail is not evidence.
Each mutation is applied by monkey-patching the module BEFORE the checker
builds its own window, then undone.
Run: python tools/check_probes_bite.py
"""
from __future__ import annotations
import io
import os
import runpy
import subprocess
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# (name, file, find, replace, checker that must FAIL because of it)
MUTATIONS = [
("phong to cham tro ly gap doi khai bao",
"ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104",
"check_layout_geometry.py"),
("tra lane Running ve khong vien",
"ui/schedule_task_tab.py",
'if status == "running" and counts[status]:',
'if False:',
"check_design_parity.py"),
("bo cot muc luc cua Cai dat",
"ui/settings_dialog.py",
"self.section_list, self.section_stack = section_panels(pages)",
"self.section_list, self.section_stack = section_panels(pages[:1])",
"check_dialogs.py"),
("noi lai dai tab flow Co4E",
"ui/co4e_tab.py",
"self.flow_scroll.setVisible(False)",
"self.flow_scroll.setVisible(True)",
"check_co4e.py"),
("bo dong 'Tat ca project...' khoi GAN DAY",
"app.py",
'more.setData(0, Qt.UserRole, {"all": True})',
'more.setData(0, Qt.UserRole, {})',
"check_design_parity.py"),
("tra thanh menu ve accordion (bo nhom day)",
"app.py",
'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
'rows.append((self.nav, self._ROW_DASHBOARD, None,',
"check_layout_geometry.py"),
]
def run_checker(script: str) -> int:
"""Run a checker in a fresh process; return its exit code."""
proc = subprocess.run(
[sys.executable, str(REPO / "tools" / script)],
cwd=REPO, capture_output=True, text=True, encoding="utf-8",
errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
"PYTHONIOENCODING": "utf-8"})
return proc.returncode
def tree_state() -> str:
return subprocess.run(["git", "status", "--short"], cwd=REPO,
capture_output=True, text=True).stdout.strip()
def main() -> int:
fails: list[str] = []
# Compare the tree BEFORE and AFTER, not against a clean tree: work in
# progress is legitimately uncommitted, and demanding a clean tree made this
# round fail for a reason that has nothing to do with the mutations.
before = tree_state()
print(f"{'hong gi':44} {'phep do':26} ket qua")
print("-" * 88)
for name, rel, find, repl, checker in MUTATIONS:
path = REPO / rel
# newline="" both ways: the default translates on read AND write, so a
# LF file came back as CRLF and every mutated file was left "modified"
# even after being restored.
with io.open(path, "r", encoding="utf-8", newline="") as fh:
original = fh.read()
if find not in original:
fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
continue
def write(text: str) -> None:
with io.open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(text)
write(original.replace(find, repl, 1))
try:
code = run_checker(checker)
finally:
write(original) # always restore
bit = code != 0
print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
if not bit:
fails.append(f"{name}: {checker} van bao xanh du da lam hong")
# Everything must be back exactly as it was before this run.
after = tree_state()
same = after == before
print()
print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
if not same:
print(" truoc:", before.replace("\n", " | ") or "(sach)")
print(" sau :", after.replace("\n", " | ") or "(sach)")
fails.append("file chua duoc khoi phuc sau khi thu")
print()
if fails:
print("*** VONG 5 THAT BAI ***")
for f in fails:
print(" " + f)
return 1
print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Round 5: do the checks actually bite?
Rounds 1–4 all report green. That is only worth something if the checks would
have turned red had the work not been done. So this round breaks the app on
purpose, one feature at a time, and fails if the corresponding check still
passes — a check that cannot fail is not evidence.
Each mutation is applied by monkey-patching the module BEFORE the checker
builds its own window, then undone.
Run: python tools/check_probes_bite.py
"""
from __future__ import annotations
import io
import os
import runpy
import subprocess
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# (name, file, find, replace, checker that must FAIL because of it)
MUTATIONS = [
("phong to cham tro ly gap doi khai bao",
"ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104",
"check_layout_geometry.py"),
("tra lane Running ve khong vien",
"ui/schedule_task_tab.py",
'if status == "running" and counts[status]:',
'if False:',
"check_design_parity.py"),
("bo cot muc luc cua Cai dat",
"ui/settings_dialog.py",
"self.section_list, self.section_stack = section_panels(pages)",
"self.section_list, self.section_stack = section_panels(pages[:1])",
"check_dialogs.py"),
("noi lai dai tab flow Co4E",
# R08-T09 doi cho: Co4ETab tach thanh 7 mixin duoi presentation/co4e/.
"presentation/co4e/co4e_layout.py",
"self.flow_scroll.setVisible(False)",
"self.flow_scroll.setVisible(True)",
"check_co4e.py"),
("bo dong 'Tat ca project...' khoi GAN DAY",
# R08-T10 doi cho: MainWindow bi boc khoi app.py sang presentation/shell/,
# RECENTS nam o rail_project.py, cay dieu huong o nav_rail.py.
"presentation/shell/rail_project.py",
'more.setData(0, Qt.UserRole, {"all": True})',
'more.setData(0, Qt.UserRole, {})',
"check_design_parity.py"),
("tra thanh menu ve accordion (bo nhom day)",
"presentation/shell/nav_rail.py",
'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
'rows.append((self.nav, self._ROW_DASHBOARD, None,',
"check_layout_geometry.py"),
]
def run_checker(script: str) -> int:
"""Run a checker in a fresh process; return its exit code."""
proc = subprocess.run(
[sys.executable, str(REPO / "tools" / script)],
cwd=REPO, capture_output=True, text=True, encoding="utf-8",
errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
"PYTHONIOENCODING": "utf-8"})
return proc.returncode
def tree_state() -> str:
return subprocess.run(["git", "status", "--short"], cwd=REPO,
capture_output=True, text=True).stdout.strip()
def main() -> int:
fails: list[str] = []
# Compare the tree BEFORE and AFTER, not against a clean tree: work in
# progress is legitimately uncommitted, and demanding a clean tree made this
# round fail for a reason that has nothing to do with the mutations.
before = tree_state()
print(f"{'hong gi':44} {'phep do':26} ket qua")
print("-" * 88)
for name, rel, find, repl, checker in MUTATIONS:
path = REPO / rel
# newline="" both ways: the default translates on read AND write, so a
# LF file came back as CRLF and every mutated file was left "modified"
# even after being restored.
with io.open(path, "r", encoding="utf-8", newline="") as fh:
original = fh.read()
if find not in original:
fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
continue
def write(text: str) -> None:
with io.open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(text)
write(original.replace(find, repl, 1))
try:
code = run_checker(checker)
finally:
write(original) # always restore
bit = code != 0
print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
if not bit:
fails.append(f"{name}: {checker} van bao xanh du da lam hong")
# Everything must be back exactly as it was before this run.
after = tree_state()
same = after == before
print()
print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
if not same:
print(" truoc:", before.replace("\n", " | ") or "(sach)")
print(" sau :", after.replace("\n", " | ") or "(sach)")
fails.append("file chua duoc khoi phuc sau khi thu")
print()
if fails:
print("*** VONG 5 THAT BAI ***")
for f in fails:
print(" " + f)
return 1
print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+18 -1514
View File
File diff suppressed because it is too large Load Diff

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