2026-09-09 16:46:15 +00:00
committed by gitea-admin
co-authored by duylh19
parent 13e2c22067
commit 1b8429e33a
147 changed files with 20993 additions and 461 deletions
+6 -6
View File
@@ -18,7 +18,7 @@ from typing import Dict, List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QMessageBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox,
QPushButton, QSplitter, QTableWidget, QTableWidgetItem, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget,
)
@@ -28,6 +28,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .dialog_buttons import ask_text, confirm, dialog_buttons
from .icons import DOT_AMBER, icon
from .widgets import fmt_tokens
@@ -151,7 +152,7 @@ class AccountEditDialog(QDialog):
self.group_combo.setCurrentIndex(idx)
self.group_combo.setEnabled(allow_group_edit)
form.addRow(tr("accounts.f_group"), self.group_combo)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -529,9 +530,8 @@ class AccountsTab(QWidget):
return
if not self._is_admin() and account.role != "user":
return # Sub-admin can only remove ordinary members, never another sub-admin
if QMessageBox.question(
self, tr("accounts.delete_title"),
tr("accounts.delete_confirm", username=account.username)) != QMessageBox.Yes:
if not confirm(self, tr("accounts.delete_title"),
tr("accounts.delete_confirm", username=account.username)):
return
accounts.delete_account(account.username, self._accounts_dir())
if account.group_id:
@@ -703,7 +703,7 @@ class AccountsTab(QWidget):
"""Hỏi tên rồi tạo một nhóm mới. Chỉ Admin."""
if not self._is_admin():
return
name, ok = QInputDialog.getText(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
name, ok = ask_text(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
if not (ok and name.strip()):
return
group = groups.new_group(name.strip())
+4 -5
View File
@@ -12,7 +12,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea,
QListWidgetItem, QPlainTextEdit, QPushButton, QScrollArea,
QSplitter, QVBoxLayout, QWidget,
)
@@ -22,6 +22,7 @@ from ..core.custom_agents import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import confirm
from .icons import icon
@@ -300,10 +301,8 @@ class AgentManagerTab(QWidget):
agent = self._current_agent()
if agent is None:
return
if QMessageBox.question(
self, tr("agentmgr.delete_btn"),
tr("agentmgr.delete_confirm", name=agent.name),
) != QMessageBox.Yes:
if not confirm(self, tr("agentmgr.delete_btn"),
tr("agentmgr.delete_confirm", name=agent.name)):
return
delete_agent(agent.name)
self._reload_list()
+4 -3
View File
@@ -9,7 +9,7 @@ from typing import List
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QInputDialog,
QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
from ..core.co4e import PERMISSION_PRESETS, CustomAgent
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline, dialog_buttons
from .icons import icon, icon_picker_combo
@@ -114,7 +115,7 @@ class Co4EAgentDialog(QDialog):
form.addRow(tr("co4e.f_attachments"), self.attach_list)
form.addRow("", abtn)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -129,7 +130,7 @@ class Co4EAgentDialog(QDialog):
role = self.role_edit.text().strip()
if not name and not role:
return
hint, ok = QInputDialog.getMultiLineText(
hint, ok = ask_multiline(
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
if not ok:
return
+27 -13
View File
@@ -390,21 +390,35 @@ class Co4ETab(
# ---- i18n -------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip."""
for key in self._sections:
self._sync_section_arrow(key)
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.wf_new_btn.setText(tr("co4e.new"))
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip.
Cột trái và trang Flow Status không có mặt ở đây: mỗi widget bên đó tự
ràng buộc khoá dịch của mình tại chỗ dựng (``i18n.bind_*``), nên panel
dùng ở đâu cũng đúng ngôn ngữ mà không cần ai nhớ hộ.
"""
self.runs_btn.setText(tr("co4e.runs_tab"))
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_back_btn.setText(tr("co4e.back_to_flow"))
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
self.runs_title.setText(tr("co4e.running_flows"))
self.run_stop_btn.setText(tr("co4e.stop"))
self.run_rename_btn.setText(tr("co4e.rename_run"))
self.run_del_btn.setText(tr("co4e.delete_run"))
self.run_clear_btn.setText(tr("co4e.clear_done"))
# Flow toolbar.
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
self.add_step_btn.setText(tr("co4e.add"))
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
self.save_btn.setText(tr("co4e.save"))
self.save_btn.setToolTip(tr("co4e.tt_save"))
self.save_tpl_btn.setToolTip(tr("co4e.tt_save_template"))
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
# By position, from the same source the items were built from: the mode
# string in each item's data is persisted, so it must survive a
# translation untouched.
for i, mode in enumerate(co4e.RUN_MODES):
self.mode_combo.setItemText(i, tr(f"co4e.mode.{mode}"))
self.run_btn.setToolTip(tr("co4e.tt_run"))
# Not a plain tr(): this button reads "Dừng" while THIS flow is running.
self._update_run_btn()
# Step-config panel header. The toggle's tooltip names the action it
# would perform, which depends on which way the panel is folded.
self.config_title.setText(tr("co4e.config_title"))
self.config_toggle_btn.setToolTip(tr(
"co4e.tt_expand_config" if self._config_collapsed else "co4e.tt_collapse_config"))
self._refresh_ws_folder_btn()
self.runs_table.setHorizontalHeaderLabels([
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
+13 -11
View File
@@ -13,14 +13,15 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..i18n import bind_text, on_language_changed, tr
from ..state import AppContext
from .dialog_buttons import confirm
from .ext_connector_dialog import ExtConnectorEditDialog
from .icons import icon
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
@@ -129,11 +130,13 @@ class ConnectorsPanel(QWidget):
Có một công tắc tổng: tắt là agent không nối ra connector ngoài nào cả.
"""
# Khoá dịch, không phải chữ sẵn: nhãn nhóm dựng lại trong _reload_connectors
# nên tr() ở đó là đủ (xem i18n/connectors.py).
_EXT_CATEGORY_LABELS = {
"cad": "CAD (NX / CATIA / SolidWorks / AutoCAD)",
"cae": "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)",
"ms365": "MS365 (Microsoft 365 / OneDrive / SharePoint)",
"other": "Other (any generic MCP server)",
"cad": "connectors.cat_cad",
"cae": "connectors.cat_cae",
"ms365": "connectors.cat_ms365",
"other": "connectors.cat_other",
}
_EXT_CATEGORY_ICONS = {"cad": "wrench", "cae": "ruler", "ms365": "cloud", "other": "plug"}
# TEMPORARY: only OneDrive + SharePoint (auto-connect via locally-synced
@@ -160,7 +163,7 @@ class ConnectorsPanel(QWidget):
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
lay.addWidget(self.connect_external_sw)
hint = QLabel(tr("settings.ext_hint"))
hint = bind_text(QLabel(), "settings.ext_hint")
hint.setObjectName("hint")
hint.setWordWrap(True)
lay.addWidget(hint)
@@ -232,7 +235,7 @@ class ConnectorsPanel(QWidget):
icon_lbl = QLabel()
icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18))
hdr.addWidget(icon_lbl)
name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (")
name, _, subtitle = tr(self._EXT_CATEGORY_LABELS.get(cat, cat)).partition(" (")
name_lbl = QLabel(name)
name_lbl.setStyleSheet("font-weight:700;")
hdr.addWidget(name_lbl)
@@ -373,9 +376,8 @@ class ConnectorsPanel(QWidget):
def _delete_ext_entry(self, cat: str, entry: dict) -> None:
"""Xoá một connector MCP sau khi hỏi xác nhận."""
if QMessageBox.question(
self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
if not confirm(self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))):
return
self.ctx.config.ext_connectors[cat].remove(entry)
self.ctx.save()
+10 -9
View File
@@ -7,7 +7,6 @@ from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
from ..core import agent_roles
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
@@ -32,9 +31,6 @@ class CoworkTab(ChatPanel):
self._title_lbl = QLabel()
self._title_lbl.setStyleSheet("font-weight:700; font-size:15px;")
self.model_lbl = QLabel("")
self.model_lbl.setObjectName("hint")
self.skills_btn = QPushButton()
self.skills_btn.setIcon(icon("book"))
self.skills_btn.clicked.connect(self._open_skills_manager)
@@ -44,7 +40,6 @@ class CoworkTab(ChatPanel):
self._new_btn.clicked.connect(self.new_session)
self.toolbar_layout.addWidget(self._title_lbl)
self.toolbar_layout.addWidget(self.model_lbl)
self.toolbar_layout.addStretch(1)
self.toolbar_layout.addWidget(self.skills_btn)
self.toolbar_layout.addWidget(self._new_btn)
@@ -104,6 +99,10 @@ class CoworkTab(ChatPanel):
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
# ChatPanel dựng phần chrome dùng chung (nhãn Agent, nút Nén, tiêu đề ba
# bảng tệp) nhưng KHÔNG tự đăng ký dịch lại — lớp con là chỗ duy nhất có
# đăng ký, nên bỏ dòng này là toàn bộ phần đó đứng ở ngôn ngữ lúc dựng.
self._retranslate_base()
self.refresh_title()
self.skills_btn.setText(tr("cowork.skills_btn"))
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
@@ -377,10 +376,12 @@ class CoworkTab(ChatPanel):
self._apply_output_folder_label()
def refresh_header(self) -> None:
"""Cập nhật dòng "provider · model" trên thanh công cụ Cowork."""
cfg = self.ctx.config
label = PROVIDER_LABELS.get(cfg.active_provider, cfg.active_provider)
self.model_lbl.setText(f"{label} · {cfg.model_label()}")
"""Cập nhật các nhãn trên thanh công cụ Cowork.
Dòng "provider · model" từng nằm ngay sau chữ "Cowork" đã được gỡ: nó
lặp lại thông tin mà bộ chọn provider ở thanh trên đang hiển thị, và
chiếm chỗ đắt nhất trên thanh công cụ cho một thứ chỉ để đọc.
"""
self._apply_output_folder_label() # picks up edits made via Settings too
def build_job(self, text: str, messages, out_dir):
+110
View File
@@ -0,0 +1,110 @@
"""Nút hộp thoại mang chữ của ứng dụng, không phải chữ mặc định của Qt.
``QDialogButtonBox(Save | Cancel)``, ``QMessageBox.question`` và
``QInputDialog.get*`` lấy nhãn nút từ bảng dịch của CHÍNH Qt. Ứng dụng không cài
``QTranslator`` nào — và bản PySide6 đang dùng cũng không đóng gói sẵn file
``qtbase_*.qm`` nào để cài — nên bảng đó luôn rơi về tiếng Anh: chọn tiếng Nhật
thì hộp thoại vẫn hiện "Save" / "Cancel" / "&Yes" / "&No". Đây là lỗi người dùng
báo, và nó không nằm ở dây nối i18n của dự án — ``tr()`` chưa từng được hỏi tới.
Các hàm dưới đây gán nhãn của dự án đè lên nhãn Qt. Dùng chúng thay cho việc
dựng ``QDialogButtonBox`` / gọi ``QMessageBox.question`` / ``QInputDialog.get*``
trực tiếp.
"""
from __future__ import annotations
from typing import Callable, Iterable, Tuple
from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QInputDialog, QMessageBox, QWidget,
)
from ..i18n import bind_text, tr
#: tên gọi trong ``**override`` -> (cờ nút chuẩn, khoá dịch mặc định)
_STANDARD = {
"save": (QDialogButtonBox.Save, "dialog.save"),
"cancel": (QDialogButtonBox.Cancel, "dialog.cancel"),
"ok": (QDialogButtonBox.Ok, "dialog.ok"),
"close": (QDialogButtonBox.Close, "dialog.close"),
}
def dialog_buttons(standard, **override: str) -> QDialogButtonBox:
"""``QDialogButtonBox`` có nhãn đổi theo ngôn ngữ đang chọn.
``override`` đổi khoá dịch của một nút cụ thể — ``ok="schedtask.ai_confirm"``
cho hộp thoại muốn nút OK mang chữ riêng. Phải đi qua đây chứ không
``setText(tr(...))`` sau khi dựng: lần đổi ngôn ngữ kế tiếp, ràng buộc bên
dưới sẽ áp lại khoá mặc định và xoá mất chữ riêng đó.
"""
box = QDialogButtonBox(standard)
for name, (flag, key) in _STANDARD.items():
button = box.button(flag)
if button is not None:
bind_text(button, override.get(name, key))
return box
def confirm(parent: QWidget | None, title: str, body: str) -> bool:
"""Hỏi Có/Không, trả về True nếu người dùng đồng ý.
Thay cho ``QMessageBox.question(...) == QMessageBox.Yes``, vốn hiện "&Yes" /
"&No" tiếng Anh ở mọi ngôn ngữ. Hộp thoại dựng lại mỗi lần mở nên gọi
``tr()`` một lần khi dựng là đủ, không cần ràng buộc.
"""
box = QMessageBox(QMessageBox.Question, title, body,
QMessageBox.Yes | QMessageBox.No, parent)
box.button(QMessageBox.Yes).setText(tr("dialog.yes"))
box.button(QMessageBox.No).setText(tr("dialog.no"))
return box.exec() == QMessageBox.Yes
def _hoi(parent: QWidget | None, title: str, label: str,
dung: Callable[[QInputDialog], None]) -> Tuple[str, bool]:
"""Khung chung của ba hàm hỏi bên dưới; ``dung`` chỉnh riêng từng kiểu.
Các hàm tĩnh ``QInputDialog.get*`` tự dựng hộp thoại bên trong nên không
chạm được vào nút của nó — phải tự dựng thì mới đặt được nhãn.
"""
dlg = QInputDialog(parent)
dlg.setWindowTitle(title)
dlg.setLabelText(label)
dlg.setOkButtonText(tr("dialog.ok"))
dlg.setCancelButtonText(tr("dialog.cancel"))
dung(dlg)
# exec() TRƯỚC, đọc giá trị SAU: gói cả hai vào một tuple thì Python tính
# textValue() trước và trả về chữ có sẵn lúc chưa ai gõ gì.
dong_y = dlg.exec() == QDialog.Accepted
return dlg.textValue(), dong_y
def ask_text(parent: QWidget | None, title: str, label: str,
text: str = "") -> Tuple[str, bool]:
"""Như ``QInputDialog.getText``, nhưng nút bấm theo ngôn ngữ đang chọn."""
return _hoi(parent, title, label, lambda d: d.setTextValue(text))
def ask_multiline(parent: QWidget | None, title: str, label: str,
text: str = "") -> Tuple[str, bool]:
"""Như ``QInputDialog.getMultiLineText``, nút bấm theo ngôn ngữ đang chọn."""
def dung(d: QInputDialog) -> None:
d.setOption(QInputDialog.UsePlainTextEditForTextInput)
d.setTextValue(text)
return _hoi(parent, title, label, dung)
def ask_item(parent: QWidget | None, title: str, label: str,
items: Iterable[str], current: int = 0,
editable: bool = False) -> Tuple[str, bool]:
"""Như ``QInputDialog.getItem``, nút bấm theo ngôn ngữ đang chọn."""
muc = list(items)
def dung(d: QInputDialog) -> None:
d.setComboBoxItems(muc)
d.setComboBoxEditable(editable)
if muc:
d.setTextValue(muc[current])
return _hoi(parent, title, label, dung)
+2 -1
View File
@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
from ..core.ext_connectors import CATEGORIES, PRESETS
from ..i18n import tr
from .dialog_buttons import dialog_buttons
# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects
# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker.
@@ -119,7 +120,7 @@ class ExtConnectorEditDialog(QDialog):
self._enabled = bool(connector.get("enabled", False))
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+4 -4
View File
@@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
QVBoxLayout, QWidget,
)
from ..core import admin_agents
from ..core import admin_agents, help_knowledge
from ..core.worker import AgentWorker
from ..i18n import tr
from .icons import icon
@@ -217,8 +217,7 @@ class HelpAgentWidget(QWidget):
# ---- greeting / labels ------------------------------------------------
def _greeting(self) -> str:
"""Câu chào mở đầu, có tên người dùng nếu biết."""
name = self._user_name or tr("help_agent.default_user")
return tr("help_agent.greeting", name=name)
return help_knowledge.greeting(self._user_name)
# ---- construction -----------------------------------------------------
def _build_edge_tab(self) -> None:
@@ -464,11 +463,12 @@ class HelpAgentWidget(QWidget):
agent = admin_agents.ensure_help_agent(
admin_agents.agents_admin_dir(self.ctx.config.shared_dir))
history = list(self._history)
sys_prompt = help_knowledge.build_prompt(agent.effective_prompt(), getattr(self.parent(), "help_context", lambda: "")())
def job(worker):
"""Chạy nền: gọi provider của Help Agent kèm prompt hệ thống của nó."""
provider = admin_agents.build_agent_provider(self.ctx, agent)
messages = [{"role": "system", "content": agent.effective_prompt()}] + history
messages = [{"role": "system", "content": sys_prompt}] + history
result = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
content = result.get("content", "") if isinstance(result, dict) else str(result)
return {"content": provider.strip_think(content) or ""}
+8 -7
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QWidget,
)
@@ -17,6 +17,7 @@ from ..core import custom_icons
from ..i18n import on_language_changed, tr
from ..state import AppContext
from . import icons as icons_mod
from .dialog_buttons import ask_multiline, ask_text
from .icons import icon
@@ -116,8 +117,8 @@ class IconsAdminTab(QWidget):
path, _ = QFileDialog.getOpenFileName(self, tr("icons_admin.add"), "", "SVG (*.svg)")
if not path:
return
name, ok = QInputDialog.getText(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
name, ok = ask_text(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
if not ok:
return
try:
@@ -129,12 +130,12 @@ class IconsAdminTab(QWidget):
def _add_from_svg_text(self) -> None:
"""Thêm icon bằng cách dán thẳng mã SVG."""
name, ok = QInputDialog.getText(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
name, ok = ask_text(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
if not ok or not name.strip():
return
svg, ok = QInputDialog.getMultiLineText(self, tr("icons_admin.paste"),
tr("icons_admin.paste_prompt"))
svg, ok = ask_multiline(self, tr("icons_admin.paste"),
tr("icons_admin.paste_prompt"))
if not ok:
return
try:
+2 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import tr
from .dialog_buttons import dialog_buttons
class McpServerEditDialog(QDialog):
@@ -41,7 +42,7 @@ class McpServerEditDialog(QDialog):
self.args.setPlaceholderText(tr("mcp.args_placeholder"))
lay.addWidget(self.args)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+12 -1
View File
@@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..i18n import tr
from ..i18n import on_language_changed, tr
class RoutingToggle(QWidget):
@@ -71,6 +71,11 @@ class RoutingToggle(QWidget):
self._label.setObjectName("hint")
self._combo = QComboBox()
self._combo.setToolTip(tr("routing.toggle_tooltip"))
# Mode names differ in length per language ("Thủ công" is wider than
# "手動"), and a combo only re-measures itself under this policy — without
# it the width stays frozen at the language the widget was built in and
# the longer translation is cut off.
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
# (data value, i18n key) — data is the persisted mode string. Order is
# least-to-most autonomous, with Fallback (R03-T03) last because it is
# the "only when something breaks" mode rather than a stronger Auto.
@@ -88,6 +93,11 @@ class RoutingToggle(QWidget):
self._combo.currentIndexChanged.connect(self._on_changed)
lay.addWidget(self._label)
lay.addWidget(self._combo)
# Registered HERE, not by the four surfaces that embed this widget
# (Cowork, Co4E, AI-Edit): every one of them had forgotten to, so the
# control stayed frozen in the language it was built in. Owning it here
# means no future embedder can forget either.
on_language_changed(self.retranslate)
def current_mode(self) -> str:
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
@@ -149,6 +159,7 @@ class AutoRunToggle(QWidget):
self.refresh()
self._chk.toggled.connect(self._on_toggled)
lay.addWidget(self._chk)
on_language_changed(self.retranslate) # same reason as RoutingToggle
def refresh(self) -> None:
"""Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""
+54 -15
View File
@@ -13,6 +13,8 @@ chưa từng được gán nên gọi vào là AttributeError.
"""
from __future__ import annotations
import secrets
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
@@ -23,6 +25,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import tr
from .dialog_buttons import dialog_buttons
from .icons import IconLabel
from .widgets import ToggleSwitch
@@ -31,6 +34,27 @@ from ..presentation.settings.general_settings_widget import GeneralSettingsWidge
from ..presentation.settings.provider_settings_widget import ProviderSettingsWidget
from ..presentation.settings.parameter_settings_widget import ParameterSettingsWidget
from ..presentation.settings.routing_settings_widget import RoutingSettingsWidget
from ..presentation.settings.about_widget import AboutSettingsWidget
def _sandbox_password_matches(entered: str, stored: str) -> bool:
"""Whether ``entered`` unlocks the Sandbox Security group.
An empty ``stored`` must never match. ``DEFAULT_CONFIG`` ships
``agent_security.sandbox_pw = ""`` and the config handed to this dialog is
always deep-merged with those defaults, so a plain ``entered == stored``
accepts an empty field on every install that never set a password. The MS365
unlock guards the same way — see ``json_config_repository.unlock_ms365``.
Both sides are compared as UTF-8 bytes, not as ``str``:
``compare_digest`` raises ``TypeError`` on ``str`` holding anything outside
ASCII, and this app defaults to Vietnamese and ships to Japanese customers,
so an accented password is ordinary input rather than an edge case.
"""
if not entered or not stored:
return False
return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8"))
class SettingsDialog(QDialog):
"""Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải
@@ -85,23 +109,23 @@ class SettingsDialog(QDialog):
sbl = QVBoxLayout(self.sandbox_group)
# --- Password protection for Sandbox Security (at top) ---
self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password")
self.sandbox_pw_label = IconLabel("lock", tr("settings.sandbox_pw_label"))
sbl.addWidget(self.sandbox_pw_label)
pw_row = QHBoxLayout()
self.sandbox_pw_edit = QLineEdit("")
self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings")
self.sandbox_pw_edit.setPlaceholderText(tr("settings.sandbox_pw_placeholder"))
self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
pw_row.addWidget(self.sandbox_pw_edit, 1)
self.sandbox_unlock_btn = QPushButton("Unlock")
self.sandbox_unlock_btn = QPushButton(tr("settings.sandbox_unlock_btn"))
self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
pw_row.addWidget(self.sandbox_unlock_btn)
self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00")
self.sandbox_locked_status = IconLabel("lock", tr("settings.sandbox_locked"), color="#c00")
self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
pw_row.addWidget(self.sandbox_locked_status)
sbl.addLayout(pw_row)
self._sandbox_unlocked = False # Start LOCKED — must enter password first
self._sandbox_pw = sec.get("sandbox_pw", "quandh14")
self._sandbox_pw = sec.get("sandbox_pw", "")
# Separator line between pw section and sandbox settings
pw_sep = QLabel("────────────────")
@@ -122,15 +146,15 @@ class SettingsDialog(QDialog):
# they belong with the other tool toggles — see ToolsAdminTab).
# --- Enable/Disable Agent Security ---
self.sec_enabled = ToggleSwitch("Enable Agent Security (command validation)")
self.sec_enabled = ToggleSwitch(tr("settings.sec_enabled"))
self.sec_enabled.setChecked(bool(sec.get("enabled", True)))
self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security")
self.sec_enabled.setToolTip(tr("settings.sec_enabled_tooltip"))
sbl.addWidget(self.sec_enabled)
# --- AI Command Check toggle ---
self.ai_check = ToggleSwitch("AI check commands")
self.ai_check = ToggleSwitch(tr("settings.ai_check"))
self.ai_check.setChecked(bool(sec.get("command_ai_check", False)))
self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy")
self.ai_check.setToolTip(tr("settings.ai_check_tooltip"))
sbl.addWidget(self.ai_check)
# Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
@@ -169,12 +193,18 @@ class SettingsDialog(QDialog):
from .widgets import section_panels
# Giới thiệu đứng CUỐI: nó không có thiết lập nào để đổi, nên đặt trước
# các mục thao tác được sẽ đẩy chúng xuống mà không được gì.
self._about_page = AboutSettingsWidget(self.ctx)
root.addWidget(self._about_page)
pages = []
for label, widget in ((tr("settings.group.general"), self._general_box),
(tr("settings.group.provider"), prov_group),
(tr("settings.group.sandbox"), self.sandbox_group),
(tr("settings.group.parameter"), param_group),
(tr("routing.settings_group"), routing_group)):
(tr("routing.settings_group"), routing_group),
(tr("settings.group.about"), self._about_page)):
root.removeWidget(widget)
page = QWidget()
pv = QVBoxLayout(page)
@@ -200,7 +230,7 @@ class SettingsDialog(QDialog):
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._save)
buttons.rejected.connect(self.reject)
outer.addWidget(buttons)
@@ -276,17 +306,26 @@ class SettingsDialog(QDialog):
phải cơ chế bảo mật thật.
"""
pw = self.sandbox_pw_edit.text()
if pw == self._sandbox_pw:
if not self._sandbox_pw:
# No password configured. Refusing with "wrong password" would be a
# dead end — the user would keep retrying a password that cannot
# exist — so name the actual state instead.
QMessageBox.warning(self, tr("settings.sandbox_pw_unset_title"),
tr("settings.sandbox_pw_unset_body"))
return
if _sandbox_password_matches(pw, self._sandbox_pw):
self._sandbox_unlocked = True
self.sandbox_locked_status.setText("Unlocked")
self.sandbox_locked_status.setText(tr("settings.sandbox_unlocked"))
self.sandbox_locked_status.set_icon("unlock", "#090")
self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
# Enable all sandbox widgets
for w in self._sandbox_widgets:
w.setEnabled(True)
QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.")
QMessageBox.information(self, tr("settings.group.sandbox"),
tr("settings.sandbox_unlocked_body"))
else:
QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.")
QMessageBox.warning(self, tr("settings.sandbox_pw_wrong_title"),
tr("settings.sandbox_pw_wrong_body"))
def _save(self) -> None:
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
+18 -10
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractItemView, QApplication, QHBoxLayout, QInputDialog, QLabel,
QLineEdit, QMenu, QMessageBox, QPushButton, QStyle, QStyledItemDelegate,
QAbstractItemView, QApplication, QHBoxLayout, QLabel,
QLineEdit, QMenu, QPushButton, QStyle, QStyledItemDelegate,
QStyleOptionViewItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
)
@@ -14,6 +14,7 @@ from ..core.history import (
rename_conversation, set_pinned,
)
from ..i18n import on_language_changed, tr
from .dialog_buttons import ask_text, confirm
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
from .widgets import CollapseStrip
from ..state import AppContext
@@ -241,8 +242,17 @@ class HistorySidebar(QWidget):
for p in projects:
groups[p.project_id] = _make_group(p.name)
# Loc theo mot project -> doc dung thu muc cua no. Khong loc ("Tat ca
# project…") -> phai doc thu muc lich su cua TUNG project roi gop lai:
# lich su nam trong thu muc lam viec cua project, nen mot lan goi
# list_conversations chi thay duoc project dang mo, va moi nhom con lai
# hien ra rong tuy nguoi dung da chat trong do.
try:
convos = list_conversations(self.ctx.config.history_dir(), query=query)
if self._project_filter:
convos = list_conversations(self.ctx.config.history_dir(), query=query)
else:
from ..core.history import history_dirs, list_conversations_by_project
convos = list_conversations_by_project(history_dirs(), query=query)
except Exception:
convos = []
@@ -337,15 +347,14 @@ class HistorySidebar(QWidget):
set_pinned(path, not pinned)
self.refresh()
elif chosen == rename_act:
new, ok = QInputDialog.getText(
new, ok = ask_text(
self, tr("sidebar.rename.title"), tr("sidebar.rename.label"), text=title)
if ok and new.strip():
rename_conversation(path, new.strip())
self.refresh()
elif chosen == del_act:
if QMessageBox.question(
self, tr("sidebar.delete.title"), tr("sidebar.delete.confirm", title=title)
) == QMessageBox.Yes:
if confirm(self, tr("sidebar.delete.title"),
tr("sidebar.delete.confirm", title=title)):
delete_conversation(path)
self.refresh()
self.history_changed.emit()
@@ -365,9 +374,8 @@ class HistorySidebar(QWidget):
"""Confirm, then delete every conversation in ``selected``. Split out
of _bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question(
self, tr("sidebar.delete.title"),
tr("sidebar.delete_multi.confirm", n=len(selected))) != QMessageBox.Yes:
if not confirm(self, tr("sidebar.delete.title"),
tr("sidebar.delete_multi.confirm", n=len(selected))):
return False
for item in selected:
path = item.data(0, Qt.UserRole)
+3 -2
View File
@@ -12,7 +12,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QInputDialog, QLabel, QListWidget,
QFileDialog, QHBoxLayout, QLabel, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget,
)
@@ -22,6 +22,7 @@ from ..core.skills import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline
from .icons import icon
from .skills_dialog import SkillEditDialog
@@ -100,7 +101,7 @@ class SkillManagerTab(QWidget):
QMessageBox.information(self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_unavailable"))
return
prompt, ok = QInputDialog.getMultiLineText(
prompt, ok = ask_multiline(
self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_prompt"), "")
if not ok or not prompt.strip():
+4 -3
View File
@@ -5,7 +5,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QInputDialog, QLabel,
QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit,
QPushButton, QVBoxLayout,
)
@@ -17,6 +17,7 @@ from ..core.skills import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline, dialog_buttons
from .icons import icon
@@ -58,7 +59,7 @@ class SkillEditDialog(QDialog):
self.instr.setMinimumHeight(180)
lay.addWidget(self.instr)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
@@ -192,7 +193,7 @@ class SkillsDialog(QDialog):
QMessageBox.information(self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_unavailable"))
return
prompt, ok = QInputDialog.getMultiLineText(
prompt, ok = ask_multiline(
self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_prompt"), "")
if not ok or not prompt.strip():
+9 -9
View File
@@ -17,7 +17,7 @@ from typing import Dict, List, Optional
from PySide6.QtCore import QDateTime, Qt
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog,
QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton,
QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
@@ -30,6 +30,7 @@ from ..core.tasks import (
from ..core.projects import list_projects
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_text, dialog_buttons
from .icons import icon
# Only these two task types can be picked when adding/editing a task — Flow,
@@ -451,7 +452,7 @@ class TaskEditorDialog(QDialog):
# matching the five real group boxes, so you always see which group you
# are in and how many are left. (Not tabs: the audit page asks for this
# shape specifically, for consistency with Settings.)
from .widgets import section_panels
from .widgets import guard_wheel, section_panels
self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule",
"schedtask.g_input", "schedtask.g_dependency",
@@ -481,7 +482,7 @@ class TaskEditorDialog(QDialog):
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close"))
buttons.accepted.connect(self._save)
@@ -490,7 +491,6 @@ class TaskEditorDialog(QDialog):
self._apply_hints()
# Scrolling the form must never spin a combo/spin/date box the cursor
# happens to hover — values only change after clicking into a field.
from .widgets import guard_wheel
guard_wheel(self)
def _retranslate_steps(self) -> None:
@@ -540,8 +540,8 @@ class TaskEditorDialog(QDialog):
def _add_link(self) -> None:
"""Thêm một link vào danh sách dữ liệu đầu vào của task."""
url, ok = QInputDialog.getText(self, tr("schedtask.add_link_title"),
tr("schedtask.add_link_label"))
url, ok = ask_text(self, tr("schedtask.add_link_title"),
tr("schedtask.add_link_label"))
url = url.strip()
if ok and url:
self.links_list.addItem(url)
@@ -607,7 +607,7 @@ class TaskEditorDialog(QDialog):
candidates = self.all_tasks + ([self._original] if self._original else [self.task])
err = chain_error(candidates, self.task["task_id"], self.next_combo.currentData())
nxt_id = self.next_combo.currentData()
warn = err or ""
warn = tr(err) if err else ""
if not err and nxt_id:
nxt = next((t for t in self.all_tasks if t["task_id"] == nxt_id), None)
if nxt and nxt.get("status") == "paused":
@@ -719,12 +719,12 @@ class TaskEditorDialog(QDialog):
err = chain_error(self.all_tasks + [self.task], self.task["task_id"],
self.next_combo.currentData())
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
deps = self._checked_depends_on()
err = depends_cycle_error(self.all_tasks + [self.task], self.task["task_id"], deps)
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
t = self.task
t["title"] = title
+13 -75
View File
@@ -21,38 +21,19 @@ from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMenu, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTabWidget,
QPlainTextEdit, QPushButton, QSplitter, QTabWidget,
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
)
from ..i18n import on_language_changed, tr
from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow
from ..state import AppContext
from .icons import collapse_left_icon, icon
from .osutil import open_folder
from .widgets import CollapseStrip
class _ProjectRow(QWidget):
"""A project in the list: its name, and under it how much is in it.
The drawing gives every row a second line — "2 đoạn chat · 3 task" — which
is the only thing on this screen that says a project holds anything at all.
"""
def __init__(self, name: str, counts: str):
"""Một dòng dự án trong danh sách: tên ở trên, số liệu tóm tắt ở dưới."""
super().__init__()
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(0)
title = QLabel(name)
sub = QLabel(counts)
sub.setObjectName("hint")
lay.addWidget(title)
lay.addWidget(sub)
class WorkspaceTab(QWidget):
class WorkspaceTab(ProjectEditingMixin, QWidget):
"""Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab
(Dự án · Cowork · Co4E · Thư mục · GraphRAG).
@@ -265,6 +246,7 @@ class WorkspaceTab(QWidget):
self._apply_pane_visibility()
self.refresh()
self.install_project_editing()
on_language_changed(self._retranslate)
self._retranslate()
@@ -520,6 +502,7 @@ class WorkspaceTab(QWidget):
self._cloud_sync_btn.setText(tr("workspace.cloud_sync"))
self._refresh_cloud_badge()
self._save_btn.setText(tr("workspace.save"))
self.retranslate_project_rows()
self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip"))
self._projects_strip.setToolTip(tr("workspace.expand_projects_tooltip"))
self.tabs.setTabText(self._project_tab_idx, tr("workspace.tab_project"))
@@ -637,7 +620,7 @@ class WorkspaceTab(QWidget):
if p.description:
item.setToolTip(p.description)
self.project_list.addItem(item)
row = _ProjectRow(p.name, tr("workspace.counts", chats=chats, tasks=tasks))
row = ProjectRow(p.name, chats, tasks)
item.setSizeHint(row.sizeHint())
self.project_list.setItemWidget(item, row)
if p.project_id == keep:
@@ -652,11 +635,11 @@ class WorkspaceTab(QWidget):
@staticmethod
def _project_counts():
"""{project_id: (chats, tasks)} — read once per refresh, not per row."""
from ..core.history import list_conversations
from ..core.history import history_dirs, list_conversations_by_project
from ..core.tasks import list_tasks
out: dict = {}
for conv in list_conversations():
for conv in list_conversations_by_project(history_dirs()):
pid = conv.get("project_id") or "default"
chats, tasks = out.get(pid, (0, 0))
out[pid] = (chats + 1, tasks)
@@ -685,7 +668,7 @@ class WorkspaceTab(QWidget):
def _load_current(self) -> None:
"""Nạp project đang chọn lên form và nối mọi sub-tab vào nó."""
from ..core.projects import load_project
from ..core.projects import load_project, project_history_dir
pid = self._selected_id()
self._current_id = pid
@@ -699,7 +682,7 @@ class WorkspaceTab(QWidget):
# sharing that folder shares the history (another machine can view +
# continue). No project → global history dir (attribute cleared).
if project is not None:
self.ctx.config._project_history_dir = project.workspace_dir() / ".cowork_history"
self.ctx.config._project_history_dir = project_history_dir(project)
else:
self.ctx.config._project_history_dir = None
if project is not None:
@@ -710,7 +693,7 @@ class WorkspaceTab(QWidget):
self._del_btn.setEnabled(True)
self._reload_threads()
if getattr(self, "_folder", None) is not None:
self._folder.set_root(str(project.workspace_dir()))
self._folder.set_project_root(str(project.workspace_dir()))
else:
self.name_edit.clear()
self.desc_edit.clear()
@@ -849,7 +832,7 @@ class WorkspaceTab(QWidget):
self.ctx.active_project_id = pid or "default"
self._refresh_mode_toggles()
if self._structure is not None:
self._structure.set_project(pid)
self._structure.set_workspace_project(pid)
if self._sidebar is not None:
self._sidebar.set_project_filter(pid) # "" → show all (no project selected)
# Route Co4E flow output into THIS project's workspace folder (so flow
@@ -902,51 +885,6 @@ class WorkspaceTab(QWidget):
self.threads.addTopLevelItem(item)
# ---- actions -----------------------------------------------------------
def _create(self) -> None:
"""Tạo project mới với tên mặc định rồi chọn nó."""
from ..core.projects import new_project
project = new_project(tr("workspace.default_new_name"))
self._current_id = project.project_id
self.refresh()
self.projects_changed.emit()
self.name_edit.setFocus()
self.name_edit.selectAll()
def _delete(self) -> None:
"""Xoá project đang chọn sau khi hỏi xác nhận."""
from ..core.projects import delete_project, load_project
pid = self._selected_id()
project = load_project(pid) if pid else None
if project is None:
return
if QMessageBox.question(
self, tr("workspace.delete"),
tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes:
return
delete_project(pid)
self._current_id = ""
self.refresh() # empty workspace → Cowork/GraphRAG hidden until a new project
self.projects_changed.emit()
self.status_message.emit(tr("workspace.deleted", name=project.name))
def _save(self) -> None:
"""Lưu tên, mô tả và chỉ dẫn chung của project đang mở."""
from ..core.projects import load_project, save_project
pid = self._current_id
project = load_project(pid) if pid else None
if project is None:
return
project.name = self.name_edit.text().strip() or project.name
project.description = self.desc_edit.text().strip()
project.instructions = self.instr_edit.toPlainText().strip()
save_project(project)
self.refresh()
self.projects_changed.emit()
self.status_message.emit(tr("workspace.saved", name=project.name))
def _pick_folder(self) -> None:
"""Chọn thư mục sandbox cho project đang mở."""
from ..core.projects import load_project, save_project
@@ -966,7 +904,7 @@ class WorkspaceTab(QWidget):
def _open_workspace(self) -> None:
"""Mở thư mục sandbox của project trong trình quản lý tệp của hệ điều hành."""
from ..core.projects import load_project
from ..core.projects import load_project, project_history_dir
project = load_project(self._current_id) if self._current_id else None
if project is None: