fix các bug theo yêu cầu https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D --------- Co-authored-by: Duy Le Huu <duylh19@fpt.com> Reviewed-on: #10 Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -59,9 +59,19 @@ class AttachmentMixin:
|
||||
lines = [text] if text else []
|
||||
|
||||
# --- User-attached files ---
|
||||
# Đường dẫn đã giải quyết của các tệp đính kèm, để vòng quét thư mục
|
||||
# phía sau không gửi lại chính chúng một lần nữa.
|
||||
da_dinh_kem = set()
|
||||
if has_attachments:
|
||||
lines.append("\n[Attachments] — read and use these files to answer the request:")
|
||||
lines.append(
|
||||
"\n[Attachments] — the user attached these files for THIS request. "
|
||||
"They are the PRIMARY subject: read them in full and base the answer "
|
||||
"on them. Anything listed further below is background context only.")
|
||||
for p in attachments:
|
||||
try:
|
||||
da_dinh_kem.add(str(Path(p).resolve()))
|
||||
except OSError:
|
||||
pass
|
||||
lines.extend(self._read_one_attachment(p, limit, notify))
|
||||
|
||||
# --- Auto-load existing workspace/output folder files as input data ---
|
||||
@@ -74,10 +84,10 @@ class AttachmentMixin:
|
||||
if workspace is not None:
|
||||
lines.extend(self._folder_input_lines(
|
||||
workspace,
|
||||
"[Workspace files] — existing files in output folder, "
|
||||
"read and use as input data. The user expects you to "
|
||||
"process these files automatically:",
|
||||
limit, max_files, notify))
|
||||
"[Workspace files] — other files that happen to sit in the output "
|
||||
"folder. Background context; do NOT let them displace the "
|
||||
"attached files or the user's own question:",
|
||||
limit, max_files, notify, da_dinh_kem))
|
||||
|
||||
# --- Project knowledge (Claude-Projects style) ---
|
||||
# Only scanned separately when it's a DIFFERENT folder from the
|
||||
@@ -88,31 +98,44 @@ class AttachmentMixin:
|
||||
if knowledge is not None and knowledge != workspace:
|
||||
lines.extend(self._folder_input_lines(
|
||||
knowledge,
|
||||
"[Project files] — shared knowledge files of this project, "
|
||||
"available to every conversation in it. Read and use them "
|
||||
"as context for the request:",
|
||||
limit, max_files, notify))
|
||||
"[Project files] — shared knowledge of this project. Background "
|
||||
"context; do NOT let them displace the attached files or "
|
||||
"the user's own question:",
|
||||
limit, max_files, notify, da_dinh_kem))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _folder_input_lines(self, folder: Path, header: str, limit: int,
|
||||
max_files: int, notify=None) -> list:
|
||||
max_files: int, notify=None, skip=frozenset()) -> list:
|
||||
"""Embed a folder's readable files into the prompt — recursing into
|
||||
every sub-folder, any depth, not just the top level, so files placed
|
||||
in nested folders are read and processed too (same per-message file
|
||||
cap as manual attachments — Settings → Attachments → max files;
|
||||
0 = unlimited — so a folder with dozens of files can't blow the
|
||||
context window)."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from ...core.doc_extract import find_input_files
|
||||
|
||||
out: list = []
|
||||
shown, total = find_input_files(folder, self._INPUT_EXTS, max_files)
|
||||
# Bo qua tep nguoi dung DA dinh kem tuong minh. Tep dinh kem thuong nam
|
||||
# ngay trong thu muc workspace, nen khong loc thi cung mot tai lieu di vao
|
||||
# prompt HAI lan: mot lan duoi [Attachments], mot lan duoi [Workspace
|
||||
# files]. Voi tai lieu dai, ban thu hai vua nhan doi ngu canh vua khien
|
||||
# model khong biet ban nao la ban duoc hoi.
|
||||
# Số tệp thư mục này thực sự trả về, ĐO TRƯỚC khi lọc trùng: dòng cảnh
|
||||
# báo bên dưới nói về giới hạn mỗi lượt, nên đếm cả tệp bị lọc vì đã
|
||||
# đính kèm sẽ báo sai là "không nạp được".
|
||||
so_lay_duoc = len(shown)
|
||||
if skip:
|
||||
shown = [f for f in shown if str(_P(f).resolve()) not in skip]
|
||||
if shown:
|
||||
out.append("\n" + header)
|
||||
for f in shown:
|
||||
out.extend(self._read_one_attachment(str(f), limit, notify))
|
||||
if total > len(shown):
|
||||
skipped = total - len(shown)
|
||||
if total > so_lay_duoc:
|
||||
skipped = total - so_lay_duoc
|
||||
out.append(f"…({skipped} more files in the folder were not "
|
||||
"loaded — per-message attachment limit; mention a "
|
||||
"file by name if the user asks about it)")
|
||||
|
||||
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.i18n import bind_dynamic, bind_tip, tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
@@ -57,7 +57,7 @@ class AudioRecorderWidget(QWidget):
|
||||
# Record / Stop toggle button
|
||||
self.record_btn = QPushButton()
|
||||
self.record_btn.setIcon(icon("microphone"))
|
||||
self.record_btn.setToolTip(tr("chat.record_audio_start") if tr("chat.record_audio_start") != "chat.record_audio_start" else "Record Voice Note")
|
||||
bind_dynamic(self.record_btn, self._sync_record_tip)
|
||||
self.record_btn.setFixedSize(32, 32)
|
||||
self.record_btn.clicked.connect(self.toggle_recording)
|
||||
layout.addWidget(self.record_btn)
|
||||
@@ -78,7 +78,7 @@ class AudioRecorderWidget(QWidget):
|
||||
|
||||
self.cancel_btn = QPushButton()
|
||||
self.cancel_btn.setIcon(icon("x"))
|
||||
self.cancel_btn.setToolTip("Cancel recording")
|
||||
bind_tip(self.cancel_btn, "chat.record_audio_cancel")
|
||||
self.cancel_btn.setFixedSize(24, 24)
|
||||
self.cancel_btn.clicked.connect(self.cancel_recording)
|
||||
status_layout.addWidget(self.cancel_btn)
|
||||
@@ -106,7 +106,7 @@ class AudioRecorderWidget(QWidget):
|
||||
self.timer_label.setText("00:00")
|
||||
self.status_container.setVisible(True)
|
||||
self.record_btn.setIcon(icon("square"))
|
||||
self.record_btn.setToolTip("Stop Recording")
|
||||
self._sync_record_tip()
|
||||
self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;")
|
||||
self._timer.start()
|
||||
self.recording_started.emit()
|
||||
@@ -137,7 +137,12 @@ class AudioRecorderWidget(QWidget):
|
||||
self.status_container.setVisible(False)
|
||||
self.record_btn.setIcon(icon("microphone"))
|
||||
self.record_btn.setStyleSheet("")
|
||||
self.record_btn.setToolTip("Record Voice Note")
|
||||
self._sync_record_tip()
|
||||
|
||||
def _sync_record_tip(self) -> None:
|
||||
"""Tooltip nút ghi âm nói việc nó sẽ làm tiếp, theo trạng thái hiện tại."""
|
||||
self.record_btn.setToolTip(tr("chat.record_audio_stop" if self._is_recording
|
||||
else "chat.record_audio_start"))
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
"""Update recording duration display every second."""
|
||||
|
||||
@@ -141,7 +141,7 @@ class ChatAgentsMixin:
|
||||
if not items and self.agent_combo.count() == 0:
|
||||
# No models found and none configured — placeholder with data=None so
|
||||
# we fall back to the provider's default model (never a fake name).
|
||||
self.agent_combo.addItem("(provider default)", None)
|
||||
self.agent_combo.addItem(tr("chat.provider_default_item"), None)
|
||||
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
|
||||
if getattr(self, "_admin_agent", None) is not None else keep)
|
||||
idx = self.agent_combo.findData(keep_data) if keep_data else -1
|
||||
|
||||
@@ -27,6 +27,7 @@ from ...state import AppContext
|
||||
from ...theme import current_palette
|
||||
from .chat_bubble_style import ThinkingIndicator
|
||||
from .chat_history_widget import ChatView
|
||||
from .chat_welcome import ChatWelcome
|
||||
from .composer_widget import Composer
|
||||
from ...ui.icons import collapse_right_icon, icon as app_icon
|
||||
from ...ui.osutil import is_image, open_path
|
||||
@@ -43,7 +44,13 @@ class ChatPanelLayoutMixin:
|
||||
cc = QVBoxLayout(chat_col)
|
||||
cc.setContentsMargins(0, 0, 0, 0)
|
||||
cc.setSpacing(0)
|
||||
# Man gioi thieu chiem dung cho cua khung chat va thay the no khi hoi
|
||||
# thoai con rong — hai thu khong bao gio cung hien.
|
||||
self.welcome = ChatWelcome()
|
||||
self.welcome.suggestion_picked.connect(self._use_suggestion)
|
||||
cc.addWidget(self.welcome, 1)
|
||||
cc.addWidget(self.chat_view, 1)
|
||||
self.chat_view.hide() # phien moi thi rong -> man gioi thieu di truoc
|
||||
self.thinking = ThinkingIndicator() # animated "working…" line while we wait
|
||||
cc.addWidget(self.thinking)
|
||||
self.center_split = QSplitter(Qt.Horizontal)
|
||||
@@ -148,3 +155,54 @@ class ChatPanelLayoutMixin:
|
||||
self.center_split.setChildrenCollapsible(False)
|
||||
self.center_split.setSizes([820, 220])
|
||||
on_language_changed(self._retranslate_base)
|
||||
|
||||
# ---- man gioi thieu ----------------------------------------------------
|
||||
def _use_suggestion(self, text: str) -> None:
|
||||
"""Thẻ gợi ý được bấm: ĐIỀN vào ô nhập, không gửi luôn.
|
||||
|
||||
Câu gợi ý là điểm bắt đầu — người dùng gần như luôn cần thêm chi tiết
|
||||
của riêng họ, và gửi ngay sẽ tiêu một lượt gọi model cho một câu hỏi
|
||||
chung chung.
|
||||
"""
|
||||
self.composer.input.setPlainText(text)
|
||||
self.composer.input.setFocus()
|
||||
|
||||
def show_welcome(self, show: bool) -> None:
|
||||
"""Bật màn giới thiệu (hội thoại rỗng) hoặc khung chat (đã có tin)."""
|
||||
welcome = getattr(self, "welcome", None)
|
||||
if welcome is None:
|
||||
return
|
||||
welcome.setVisible(show)
|
||||
self.chat_view.setVisible(not show)
|
||||
if show:
|
||||
welcome.refresh(**self._welcome_context())
|
||||
|
||||
def _welcome_context(self) -> dict:
|
||||
"""Dữ liệu cho dòng bối cảnh. Không biết thì trả -1, KHÔNG trả 0.
|
||||
|
||||
Hiện "0 tệp" khi người dùng vừa nhìn thấy tệp trong thư mục còn tệ hơn
|
||||
là bỏ mảnh đó khỏi dòng meta.
|
||||
"""
|
||||
from pathlib import Path as _P
|
||||
|
||||
ten = ""
|
||||
try:
|
||||
from ...core.projects import load_project
|
||||
project = load_project(self.project_id) if getattr(self, "project_id", "") else None
|
||||
ten = project.name if project is not None else ""
|
||||
except Exception: # noqa: BLE001
|
||||
ten = ""
|
||||
|
||||
so_tep = -1
|
||||
try:
|
||||
folder = self.workspace_dir()
|
||||
if folder is not None and _P(folder).is_dir():
|
||||
so_tep = sum(1 for f in _P(folder).rglob("*")
|
||||
if f.is_file() and f.suffix.lower() in self._INPUT_EXTS)
|
||||
except Exception: # noqa: BLE001
|
||||
so_tep = -1
|
||||
|
||||
# Ten nguoi dung do cua so chinh giu (app.py truyen xuong MainWindow).
|
||||
window = self.window()
|
||||
return {"user_name": getattr(window, "_user_name", "") or "",
|
||||
"project": ten, "files": so_tep}
|
||||
|
||||
@@ -11,9 +11,9 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import confirm
|
||||
|
||||
|
||||
class ChatSessionMixin:
|
||||
@@ -194,6 +194,8 @@ class ChatSessionMixin:
|
||||
"""
|
||||
from ...core.history import new_session_id
|
||||
|
||||
self.show_welcome(True) # hội thoại rỗng -> màn giới thiệu
|
||||
|
||||
# Allowed while work is running: current turns keep going in the background.
|
||||
self._detach_live_turns()
|
||||
self.messages = []
|
||||
@@ -230,6 +232,8 @@ class ChatSessionMixin:
|
||||
# turn must NOT tear down its live rendering — just no-op.
|
||||
if sid == self.session_id and self._view_busy():
|
||||
return
|
||||
# Hoi thoai da luu thi co tin nhan -> khung chat, khong phai man gioi thieu.
|
||||
self.show_welcome(not (conv.get("messages") or []))
|
||||
self._detach_live_turns()
|
||||
self.session_id = sid
|
||||
self.title = conv.get("title", "")
|
||||
@@ -304,7 +308,7 @@ class ChatSessionMixin:
|
||||
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
|
||||
else:
|
||||
prompt = tr("chatpanel.delete_confirm_plain")
|
||||
if QMessageBox.question(self, tr("chatpanel.delete_confirm_title"), prompt) != QMessageBox.Yes:
|
||||
if not confirm(self, tr("chatpanel.delete_confirm_title"), prompt):
|
||||
return
|
||||
for bubble in turn.get("bubbles", []):
|
||||
bubble.setParent(None)
|
||||
|
||||
@@ -65,6 +65,10 @@ class ChatTurnRunnerMixin:
|
||||
attachments = attachments or []
|
||||
typed = text
|
||||
prefix, request, info = self._apply_skill_command(text)
|
||||
# Moi duong tra ve som duoi day cung them mot bong nguoi dung vao khung,
|
||||
# nen man gioi thieu phai nhuong cho ngay tai day — dat sau tung
|
||||
# add_user() thi de sot dung mot nhanh, va nhanh do se hien ca hai thu.
|
||||
self.show_welcome(False)
|
||||
if info is not None:
|
||||
# A local /skill command (list / select / error) — answer inline.
|
||||
self.chat_view.add_user(typed)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Màn giới thiệu của khung chat khi hội thoại còn rỗng.
|
||||
|
||||
Bấm "Cuộc trò chuyện mới" trước đây để lại một khung trắng: không có gì nói
|
||||
người dùng đang làm trong project nào, thư mục có bao nhiêu tệp, hay bắt đầu từ
|
||||
đâu. Đây là trạng thái RỖNG — một trong bốn trạng thái mà mọi khung dữ liệu phải
|
||||
có (xem ``agent/checklist/ux_review.md``), và là trạng thái duy nhất người dùng
|
||||
nhìn thấy trước khi họ gõ chữ đầu tiên.
|
||||
|
||||
Bốn thẻ gợi ý ĐIỀN vào ô nhập chứ không gửi luôn: câu gợi ý là điểm bắt đầu, và
|
||||
người dùng gần như luôn cần thêm chi tiết của riêng họ trước khi gửi. Gửi ngay
|
||||
sẽ tiêu một lượt gọi model cho một câu hỏi chung chung.
|
||||
|
||||
Dấu trang trí phía trên lời chào không bấm được — nó là một dấu hiệu thị giác,
|
||||
không phải nút. Một nút không làm gì tệ hơn không có nút.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...i18n import on_language_changed, tr
|
||||
from ...ui.icons import icon
|
||||
|
||||
#: Width of the four-card block. A FLOOR for the cap, not a fixed number: the
|
||||
#: block never gets narrower than this, but the cap grows when the text needs
|
||||
#: more room. One number measured against English at 100% scale is exactly how
|
||||
#: the titles end up clipped in Vietnamese and Japanese (``qt_pitfalls.md`` P02).
|
||||
_GRID_WIDTH_FLOOR = 460
|
||||
|
||||
#: (khoá tiêu đề, khoá mô tả, khoá câu gợi ý, tên icon) cho từng thẻ.
|
||||
_CARDS = (
|
||||
("welcome.card_docs", "welcome.card_docs_sub", "welcome.prompt_docs", "file"),
|
||||
("welcome.card_data", "welcome.card_data_sub", "welcome.prompt_data", "table"),
|
||||
("welcome.card_schedule", "welcome.card_schedule_sub", "welcome.prompt_schedule", "schedule"),
|
||||
("welcome.card_graph", "welcome.card_graph_sub", "welcome.prompt_graph", "graph"),
|
||||
)
|
||||
|
||||
|
||||
class _Card(QPushButton):
|
||||
"""Một thẻ gợi ý: icon, tiêu đề, và một dòng mô tả bên dưới."""
|
||||
|
||||
def __init__(self, title_key: str, sub_key: str, icon_name: str,
|
||||
parent: QWidget | None = None) -> None:
|
||||
"""Thẻ gợi ý. Là ``QPushButton`` để có sẵn hover, focus và bàn phím."""
|
||||
super().__init__(parent)
|
||||
self._title_key = title_key
|
||||
self._sub_key = sub_key
|
||||
self.setObjectName("welcomeCard")
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
# Vertically it must be able to GROW: QPushButton defaults to Fixed, so
|
||||
# a card whose description fits on one line was centred inside a row as
|
||||
# tall as its two-line neighbour — two cards side by side, staggered and
|
||||
# of different heights.
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding)
|
||||
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(12, 10, 12, 10)
|
||||
row.setSpacing(10)
|
||||
|
||||
self._icon = QLabel()
|
||||
self._icon.setPixmap(icon(icon_name).pixmap(18, 18))
|
||||
self._icon.setAlignment(Qt.AlignTop)
|
||||
row.addWidget(self._icon)
|
||||
|
||||
col = QVBoxLayout()
|
||||
col.setContentsMargins(0, 0, 0, 0)
|
||||
col.setSpacing(2)
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setObjectName("welcomeCardTitle")
|
||||
self.sub_label = QLabel()
|
||||
self.sub_label.setObjectName("hint")
|
||||
self.sub_label.setWordWrap(True)
|
||||
col.addWidget(self.title_label)
|
||||
col.addWidget(self.sub_label)
|
||||
row.addLayout(col, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
# ---- size: taken from the child layout, not from the button's own text -- #
|
||||
# QPushButton computes sizeHint/minimumSizeHint from ITS OWN text and icon
|
||||
# and ignores the child layout. This card leaves both of those empty on
|
||||
# purpose (the two QLabels below draw the text; a non-empty text() prints
|
||||
# on top of them), so the button reported 54x15 while its layout asked for
|
||||
# 258x48 — the two QLabels and the icon cell were handed 0px of height, and
|
||||
# what the user saw was four empty frames with no text and no icon. The two
|
||||
# overrides below report the size the content actually needs.
|
||||
|
||||
def sizeHint(self): # noqa: N802 - Qt override
|
||||
"""Size the card's own content needs, not the (empty) button label."""
|
||||
return self.layout().sizeHint()
|
||||
|
||||
def minimumSizeHint(self): # noqa: N802 - Qt override
|
||||
"""Floor comes from the child layout, for the same reason."""
|
||||
return self.layout().minimumSize()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.title_label.setText(tr(self._title_key))
|
||||
self.sub_label.setText(tr(self._sub_key))
|
||||
# Nhãn của chính QPushButton để rỗng — chữ do hai QLabel bên trong vẽ,
|
||||
# đặt cả hai chỗ sẽ in đè lên nhau.
|
||||
self.setAccessibleName(tr(self._title_key))
|
||||
# New text means a new content size — Japanese and Vietnamese are not
|
||||
# the same length, and sizeHint is computed from those two QLabels.
|
||||
self.updateGeometry()
|
||||
|
||||
|
||||
class ChatWelcome(QWidget):
|
||||
"""Trạng thái rỗng của khung chat: lời chào, dòng bối cảnh, bốn thẻ gợi ý."""
|
||||
|
||||
#: Người dùng chọn một thẻ. Mang theo câu gợi ý để chỗ nhận điền vào ô nhập.
|
||||
suggestion_picked = Signal(str)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
"""Dựng màn giới thiệu. Chữ được điền qua :meth:`refresh`."""
|
||||
super().__init__(parent)
|
||||
self.setObjectName("chatWelcome")
|
||||
self._user_name = ""
|
||||
self._meta_parts: list = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(24, 24, 24, 24)
|
||||
root.addStretch(1)
|
||||
|
||||
mark_row = QHBoxLayout()
|
||||
mark_row.addStretch(1)
|
||||
self._mark = QLabel()
|
||||
self._mark.setObjectName("welcomeMark")
|
||||
self._mark.setPixmap(icon("sparkle").pixmap(20, 20))
|
||||
self._mark.setAlignment(Qt.AlignCenter)
|
||||
self._mark.setFixedSize(38, 38)
|
||||
mark_row.addWidget(self._mark)
|
||||
mark_row.addStretch(1)
|
||||
root.addLayout(mark_row)
|
||||
root.addSpacing(14)
|
||||
|
||||
self.greeting_label = QLabel()
|
||||
self.greeting_label.setObjectName("welcomeGreeting")
|
||||
self.greeting_label.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(self.greeting_label)
|
||||
|
||||
self.meta_label = QLabel()
|
||||
self.meta_label.setObjectName("hint")
|
||||
self.meta_label.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(self.meta_label)
|
||||
root.addSpacing(18)
|
||||
|
||||
grid_row = QHBoxLayout()
|
||||
grid_row.addStretch(1)
|
||||
self._grid_host = QWidget()
|
||||
self._grid = QGridLayout(self._grid_host)
|
||||
self._grid.setContentsMargins(0, 0, 0, 0)
|
||||
self._grid.setSpacing(10)
|
||||
self.cards: list = []
|
||||
for i, (title_key, sub_key, prompt_key, icon_name) in enumerate(_CARDS):
|
||||
card = _Card(title_key, sub_key, icon_name)
|
||||
card.clicked.connect(
|
||||
lambda _checked=False, key=prompt_key: self.suggestion_picked.emit(tr(key)))
|
||||
self._grid.addWidget(card, i // 2, i % 2)
|
||||
self.cards.append(card)
|
||||
self._apply_grid_width()
|
||||
grid_row.addWidget(self._grid_host)
|
||||
grid_row.addStretch(1)
|
||||
root.addLayout(grid_row)
|
||||
|
||||
root.addStretch(2)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _apply_grid_width(self) -> None:
|
||||
"""Cap the card block at the wider of the design width and what text needs.
|
||||
|
||||
Recomputed on every language change: ``vi`` and ``ja`` labels are not
|
||||
the same length as ``en``, and a cap fixed at build time clips whichever
|
||||
language happens to be longer.
|
||||
"""
|
||||
self._grid_host.setMaximumWidth(
|
||||
max(_GRID_WIDTH_FLOOR, self._grid.sizeHint().width()))
|
||||
|
||||
# ---- nội dung ----------------------------------------------------------
|
||||
|
||||
def refresh(self, user_name: str = "", project: str = "",
|
||||
files: int = -1) -> None:
|
||||
"""Cập nhật lời chào và dòng bối cảnh.
|
||||
|
||||
``files`` bằng ``-1`` nghĩa là KHÔNG BIẾT, và phần đó bị bỏ khỏi dòng
|
||||
meta — thà thiếu một mảnh còn hơn hiện số 0 mà người dùng vừa thấy có
|
||||
tệp trong thư mục.
|
||||
|
||||
Không hiện số skill đang bật: nó không giúp người dùng quyết định gõ gì
|
||||
vào ô nhập, mà lại chiếm một phần ba của dòng bối cảnh.
|
||||
"""
|
||||
self._user_name = (user_name or "").strip()
|
||||
parts = []
|
||||
if (project or "").strip():
|
||||
parts.append(tr("welcome.meta_project", name=project.strip()))
|
||||
if files >= 0:
|
||||
parts.append(tr("welcome.meta_files", n=files))
|
||||
self._meta_parts = parts
|
||||
self._retranslate()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
if self._user_name:
|
||||
self.greeting_label.setText(tr("welcome.greeting", name=self._user_name))
|
||||
else:
|
||||
self.greeting_label.setText(tr("welcome.greeting_anon"))
|
||||
self.meta_label.setText(" · ".join(self._meta_parts))
|
||||
self.meta_label.setVisible(bool(self._meta_parts))
|
||||
for card in self.cards:
|
||||
card.retranslate()
|
||||
self._apply_grid_width()
|
||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_text, bind_tip
|
||||
from ...ui.icons import icon
|
||||
from .palette_list import _PaletteList
|
||||
|
||||
@@ -55,9 +55,11 @@ class AgentListPanel(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
|
||||
super().__init__(parent)
|
||||
self.new_btn = QPushButton(tr("co4e.new"))
|
||||
# Bound, not set once: this panel has no retranslate hook of its own, and
|
||||
# Co4ETab (which owns the language callback) cannot reach these tooltips.
|
||||
self.new_btn = bind_text(QPushButton(), "co4e.new")
|
||||
self.new_btn.setIcon(icon("plus"))
|
||||
self.new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
||||
bind_tip(self.new_btn, "co4e.tt_new_agent")
|
||||
self.new_btn.setObjectName("co4eSectionAction")
|
||||
self.new_btn.setFlat(True)
|
||||
self.new_btn.setCursor(Qt.PointingHandCursor)
|
||||
@@ -75,11 +77,11 @@ class AgentListPanel(QWidget):
|
||||
# Edit/delete act on the selected row, so they stay with the list.
|
||||
self.edit_btn = QPushButton()
|
||||
self.edit_btn.setIcon(icon("edit"))
|
||||
self.edit_btn.setToolTip(tr("co4e.tt_edit_agent"))
|
||||
bind_tip(self.edit_btn, "co4e.tt_edit_agent")
|
||||
self.edit_btn.setFixedWidth(34)
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setToolTip(tr("co4e.tt_del_agent"))
|
||||
bind_tip(self.del_btn, "co4e.tt_del_agent")
|
||||
self.del_btn.setFixedWidth(34)
|
||||
# KHONG noi .clicked o day: cung ly do nhu new_btn o tren.
|
||||
btns.addWidget(self.edit_btn)
|
||||
|
||||
@@ -34,6 +34,7 @@ from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
|
||||
from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu
|
||||
|
||||
from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
from .canvas_geometry import _elide, _rounded_path
|
||||
|
||||
@@ -233,9 +234,9 @@ class _NodeItem(QGraphicsObject):
|
||||
def contextMenuEvent(self, e):
|
||||
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
|
||||
menu = QMenu()
|
||||
a_add = menu.addAction("+ Add next step")
|
||||
a_conn = menu.addAction("→ Connect from here")
|
||||
a_del = menu.addAction("🗑 Delete step")
|
||||
a_add = menu.addAction("+ " + tr("co4e.canvas_add_next"))
|
||||
a_conn = menu.addAction("→ " + tr("co4e.canvas_connect_from"))
|
||||
a_del = menu.addAction("🗑 " + tr("co4e.delete_step"))
|
||||
chosen = menu.exec(e.screenPos())
|
||||
if chosen is a_add:
|
||||
self.canvas.add_step_below(self.node.id)
|
||||
@@ -342,7 +343,7 @@ class _EdgeItem(QGraphicsPathItem):
|
||||
def contextMenuEvent(self, e):
|
||||
"""Menu chuột phải trên đường nối: xoá liên kết."""
|
||||
menu = QMenu()
|
||||
act_del = menu.addAction("🗑 Delete connection")
|
||||
act_del = menu.addAction("🗑 " + tr("co4e.canvas_delete_edge"))
|
||||
if menu.exec(e.screenPos()) is act_del:
|
||||
self.canvas.delete_edge(self.edge)
|
||||
e.accept()
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 ...i18n import bind_dynamic, tr
|
||||
from ...ui.chat_view import ChatView
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import ChatPanel
|
||||
@@ -55,6 +55,12 @@ class Co4EChatMixin:
|
||||
self._co4e_routed_provider = None # routing provider override for the next turn
|
||||
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
|
||||
self._msgs_collapsed = True
|
||||
# The tooltip names the action the button would perform, so it depends on
|
||||
# which way the box is folded — and the fold state lives here, not in the
|
||||
# panel. Bound so a language change re-reads it instead of freezing the
|
||||
# wording set when the tab was built.
|
||||
bind_dynamic(self.chat_toggle_btn, lambda: self.chat_toggle_btn.setToolTip(
|
||||
tr("co4e.tt_expand_msgs" if self._msgs_collapsed else "co4e.tt_collapse_msgs")))
|
||||
return panel
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Show/hide the WHOLE chat box (message list + composer) below the
|
||||
|
||||
@@ -48,7 +48,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...core.co4e_builtins import BUILTIN_AGENTS
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_placeholder, bind_text, tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.icons import icon
|
||||
from ...ui.routing_toggle import RoutingToggle
|
||||
@@ -223,7 +223,8 @@ class ChatPanel(QWidget):
|
||||
self.header = QWidget(); self.header.setObjectName("msgHeader")
|
||||
mh = QHBoxLayout(self.header); mh.setContentsMargins(6, 3, 6, 3); mh.setSpacing(6)
|
||||
self.msgs_icon = QLabel(); self.msgs_icon.setPixmap(icon("message").pixmap(14, 14))
|
||||
self.msgs_title = QLabel(tr("co4e.messages")); self.msgs_title.setObjectName("hint")
|
||||
self.msgs_title = bind_text(QLabel(), "co4e.messages")
|
||||
self.msgs_title.setObjectName("hint")
|
||||
self.chat_toggle_btn = QPushButton()
|
||||
self.chat_toggle_btn.setObjectName("msgToggle")
|
||||
self.chat_toggle_btn.setFlat(True)
|
||||
@@ -253,10 +254,11 @@ class ChatPanel(QWidget):
|
||||
crow.addWidget(self.usage_total_lbl)
|
||||
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
|
||||
self.chat_input = _ChatInput()
|
||||
self.chat_input.setPlaceholderText(tr("co4e.chat_placeholder"))
|
||||
bind_placeholder(self.chat_input, "co4e.chat_placeholder")
|
||||
# KHONG noi .submit o day: cung ly do nhu chat_toggle_btn o tren
|
||||
# (ben goi noi toi _chat_send cua chinh no).
|
||||
self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send"))
|
||||
self.chat_send_btn = bind_text(QPushButton(), "co4e.send")
|
||||
self.chat_send_btn.setIcon(icon("send"))
|
||||
# KHONG noi .clicked o day: cung ly do nhu tren.
|
||||
row.addWidget(self.chat_input, 1)
|
||||
# Off/Auto/Manual routing toggle for Co4E (surface key "co4e").
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import List
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.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 ...i18n import bind_text, tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.co4e_canvas import Co4ECanvas
|
||||
from ...ui.icons import icon
|
||||
@@ -141,7 +141,9 @@ class Co4ELayoutMixin:
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_btn.toggled.connect(self._show_runs)
|
||||
|
||||
bar.addWidget(QLabel(tr("co4e.flow_name")))
|
||||
# Bound: nothing else holds this label, so a one-shot tr() here would
|
||||
# leave "Flow" stuck in the language the toolbar was built in.
|
||||
bar.addWidget(bind_text(QLabel(), "co4e.flow_name"))
|
||||
bar.addWidget(self.name_edit, 1)
|
||||
bar.addWidget(self.add_step_btn)
|
||||
bar.addWidget(self.save_btn)
|
||||
|
||||
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_text, bind_tip
|
||||
from ...ui.icons import icon
|
||||
|
||||
|
||||
@@ -66,13 +66,15 @@ class RunsPagePanel(QWidget):
|
||||
hdr = QHBoxLayout()
|
||||
# The Runs page covers the flow toolbar, so it carries its own way back —
|
||||
# otherwise the toggle that opened it is off screen.
|
||||
self.back_btn = QPushButton(tr("co4e.back_to_flow"))
|
||||
# Bound, not set once: this panel has no retranslate hook of its own, and
|
||||
# Co4ETab (which owns the language callback) cannot reach these strings.
|
||||
self.back_btn = bind_text(QPushButton(), "co4e.back_to_flow")
|
||||
self.back_btn.setIcon(icon("chevron-left"))
|
||||
self.back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
bind_tip(self.back_btn, "co4e.tt_back_to_flow")
|
||||
# KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao
|
||||
# xu ly - panel chi dung widget, khong biet _show_runs la gi.
|
||||
hdr.addWidget(self.back_btn)
|
||||
self.title_label = QLabel(tr("co4e.running_flows"))
|
||||
self.title_label = bind_text(QLabel(), "co4e.running_flows")
|
||||
self.title_label.setObjectName("hint")
|
||||
hdr.addWidget(self.title_label)
|
||||
# Show + open the workspace folder where flow outputs land (below the tab,
|
||||
@@ -85,21 +87,21 @@ class RunsPagePanel(QWidget):
|
||||
# ca hai deu thuoc Co4ETab (can ctx/manager de biet duong dan that).
|
||||
hdr.addWidget(self.ws_folder_btn)
|
||||
hdr.addStretch(1)
|
||||
self.stop_btn = QPushButton(tr("co4e.stop"))
|
||||
self.stop_btn = bind_text(QPushButton(), "co4e.stop")
|
||||
self.stop_btn.setIcon(icon("stop"))
|
||||
self.stop_btn.setObjectName("danger")
|
||||
self.stop_btn.setToolTip(tr("co4e.tt_stop_run"))
|
||||
bind_tip(self.stop_btn, "co4e.tt_stop_run")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
|
||||
self.rename_btn = QPushButton(tr("co4e.rename_run"))
|
||||
self.rename_btn = bind_text(QPushButton(), "co4e.rename_run")
|
||||
self.rename_btn.setIcon(icon("edit"))
|
||||
self.rename_btn.setToolTip(tr("co4e.tt_rename_run"))
|
||||
bind_tip(self.rename_btn, "co4e.tt_rename_run")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
|
||||
self.del_btn = QPushButton(tr("co4e.delete_run"))
|
||||
self.del_btn = bind_text(QPushButton(), "co4e.delete_run")
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setToolTip(tr("co4e.tt_delete_run"))
|
||||
bind_tip(self.del_btn, "co4e.tt_delete_run")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
|
||||
self.clear_btn = QPushButton(tr("co4e.clear_done"))
|
||||
self.clear_btn.setToolTip(tr("co4e.tt_clear_runs"))
|
||||
self.clear_btn = bind_text(QPushButton(), "co4e.clear_done")
|
||||
bind_tip(self.clear_btn, "co4e.tt_clear_runs")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren. (Ban goc
|
||||
# noi thang toi lambda: self.manager.clear_finished(), khong qua mot
|
||||
# method rieng - Co4ETab van giu dung quirk do khi noi lai signal nay.)
|
||||
@@ -113,7 +115,7 @@ class RunsPagePanel(QWidget):
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.table.setToolTip(tr("co4e.tt_runs_list"))
|
||||
bind_tip(self.table, "co4e.tt_runs_list")
|
||||
# KHONG noi .itemDoubleClicked o day: cung ly do nhu back_btn o tren.
|
||||
# Right-click a run → Open / Delete (delete a single old run from history).
|
||||
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
|
||||
@@ -13,7 +13,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem
|
||||
from PySide6.QtWidgets import QMenu, QMessageBox, QTableWidget, QTableWidgetItem
|
||||
from ...core import co4e
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
@@ -337,9 +337,9 @@ class Co4ERunsMixin:
|
||||
h = self.manager.get(run_id)
|
||||
if h is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
|
||||
tr("co4e.rename_run_label"), text=h.name)
|
||||
from ...ui.dialog_buttons import ask_text
|
||||
new, ok = ask_text(self, tr("co4e.rename_run"),
|
||||
tr("co4e.rename_run_label"), text=h.name)
|
||||
new = (new or "").strip()
|
||||
if not ok or not new or new == h.name:
|
||||
return
|
||||
|
||||
@@ -11,14 +11,42 @@ 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 ...i18n import bind_dynamic, bind_text, bind_tip, 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
|
||||
|
||||
|
||||
def _skill_prefix_lookup(all_skills):
|
||||
"""Answer ``skills.skill_prefix_for`` from an ALREADY-LOADED skill list.
|
||||
|
||||
``skill_prefix_for`` re-reads the whole skill folder on every call, so
|
||||
asking it once per skill made a sidebar reload cost one full disk scan per
|
||||
skill — measured at ~3.8s of frozen GUI thread on a 121-skill library, and
|
||||
that reload runs on every language switch.
|
||||
|
||||
The scan order and the blank-instructions rule are copied from
|
||||
``skill_prefix_for`` deliberately: a namesake with no instructions must NOT
|
||||
end the search, or a skill's text silently becomes empty in an agent prompt.
|
||||
"""
|
||||
cache: dict = {}
|
||||
|
||||
def lookup(name: str) -> str:
|
||||
"""The ``## Skill: <name>\\n<instructions>`` block for one name, or ''."""
|
||||
if not name:
|
||||
return ""
|
||||
low = name.strip().lower()
|
||||
if low not in cache:
|
||||
cache[low] = next(
|
||||
(f"## Skill: {s.name}\n{s.instructions.strip()}" for s in all_skills
|
||||
if (s.slug == low or s.name.lower() == low) and s.instructions.strip()),
|
||||
"")
|
||||
return cache[low]
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
class Co4ESidebarMixin:
|
||||
"""Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status."""
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
@@ -66,9 +94,12 @@ class Co4ESidebarMixin:
|
||||
col = _Col(self.side_split)
|
||||
|
||||
# --- WORKFLOWS ---------------------------------------------------
|
||||
self.wf_new_btn = QPushButton(tr("co4e.new"))
|
||||
# Bound, not set once: Co4ETab._retranslate reloads the sidebar's LIST
|
||||
# CONTENTS, but these headings, buttons and tooltips are built here and
|
||||
# nothing re-applied them — they stayed in the language of app start-up.
|
||||
self.wf_new_btn = bind_text(QPushButton(), "co4e.new")
|
||||
self.wf_new_btn.setIcon(icon("plus"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
bind_tip(self.wf_new_btn, "co4e.tt_new_wf")
|
||||
self.wf_new_btn.setObjectName("co4eSectionAction")
|
||||
self.wf_new_btn.setFlat(True)
|
||||
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
|
||||
@@ -78,7 +109,7 @@ class Co4ESidebarMixin:
|
||||
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
|
||||
# double-click loads it onto the canvas.
|
||||
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
|
||||
self.wf_list.setToolTip(tr("co4e.drag_hint"))
|
||||
bind_tip(self.wf_list, "co4e.drag_hint")
|
||||
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
|
||||
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
|
||||
@@ -93,8 +124,9 @@ class Co4ESidebarMixin:
|
||||
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 = bind_text(QPushButton(), "co4e.run_bg")
|
||||
self.wf_runbg_btn.setIcon(icon("play"))
|
||||
bind_tip(self.wf_runbg_btn, "co4e.tt_run_bg")
|
||||
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
|
||||
wl.addWidget(self.wf_runbg_btn)
|
||||
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
|
||||
@@ -135,12 +167,12 @@ class Co4ESidebarMixin:
|
||||
self.runs_more_btn.setIcon(icon("chevron-right"))
|
||||
self.runs_more_btn.setFixedWidth(30)
|
||||
self.runs_more_btn.setFlat(True)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
bind_tip(self.runs_more_btn, "co4e.tt_runs_tab")
|
||||
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
|
||||
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"))
|
||||
bind_tip(self.runs_side_list, "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)
|
||||
@@ -202,7 +234,10 @@ class Co4ESidebarMixin:
|
||||
v.addWidget(body, 1)
|
||||
|
||||
self._sections[key] = (head, body, stretch)
|
||||
self._sync_section_arrow(key)
|
||||
# bind_dynamic, not bind_text: the heading is the fold arrow plus the
|
||||
# translated name in caps, so re-applying it means re-running the whole
|
||||
# line rather than pushing one key into setText.
|
||||
bind_dynamic(head, lambda k=key: self._sync_section_arrow(k))
|
||||
return box
|
||||
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.
|
||||
@@ -223,7 +258,7 @@ class Co4ESidebarMixin:
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
|
||||
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
|
||||
"""Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục."""
|
||||
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
|
||||
b = QPushButton(); b.setIcon(icon(icon_name)); bind_tip(b, tip_key)
|
||||
b.setFixedWidth(34)
|
||||
b.clicked.connect(slot)
|
||||
return b
|
||||
@@ -254,13 +289,16 @@ class Co4ESidebarMixin:
|
||||
co4e._step_dict(step))
|
||||
it.setData(Qt.UserRole + 1, ca.id)
|
||||
self.agent_list.addItem(it)
|
||||
# Skills
|
||||
# Skills — the library is read ONCE here and both the names and the
|
||||
# instructions come out of that one read (see _skill_prefix_lookup).
|
||||
self.skill_list.clear()
|
||||
for name in _skill_names():
|
||||
content = skills_mod.skill_prefix_for(name)
|
||||
all_skills = skills_mod.list_skills() + skills_mod.builtin_skills()
|
||||
skill_prefix = _skill_prefix_lookup(all_skills)
|
||||
for skill in all_skills:
|
||||
name = skill.name
|
||||
payload = co4e._step_dict(co4e.Step(
|
||||
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
|
||||
instructions=content, skills=[name]))
|
||||
instructions=skill_prefix(name), skills=[name]))
|
||||
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
|
||||
@staticmethod
|
||||
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
|
||||
|
||||
@@ -8,10 +8,11 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QInputDialog, QMenu
|
||||
from PySide6.QtWidgets import QMenu
|
||||
from ...core import co4e
|
||||
from ...core.co4e import STEP_DONE, STEP_RUNNING
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import ask_text
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
|
||||
@@ -118,8 +119,8 @@ class Co4EWorkflowCrudMixin:
|
||||
wf = co4e.get_workflow(ident)
|
||||
if wf is None:
|
||||
return
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
|
||||
text=wf.name)
|
||||
name, ok = ask_text(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
|
||||
text=wf.name)
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
@@ -24,20 +24,21 @@ thứ tự kế thừa không ảnh hưởng hành vi (khác trường hợp
|
||||
``co4e_canvas_widget.py``, nơi thứ tự mixin-trước-Qt-base là bắt buộc vì có
|
||||
override trùng tên).
|
||||
|
||||
Import trong từng method giữ nguyên y hệt bản gốc (kể cả các import cục bộ có
|
||||
vẻ thừa như ``from PySide6.QtWidgets import QInputDialog`` lặp lại bên trong
|
||||
``_add_subagent``/``_edit_subagent`` dù đã có ở top-level) — chỉ số cấp `..`
|
||||
được nâng lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
|
||||
``presentation/co4e/`` (cách gốc 3 cấp).
|
||||
Import trong từng method giữ nguyên y hệt bản gốc — chỉ số cấp `..` được nâng
|
||||
lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
|
||||
``presentation/co4e/`` (cách gốc 3 cấp). Riêng các lời gọi ``QInputDialog``
|
||||
đã chuyển sang ``ui.dialog_buttons``: hàm tĩnh của Qt tự dựng hộp thoại bên
|
||||
trong nên nút "Cancel" của nó luôn là tiếng Anh.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtWidgets import QInputDialog, QListWidgetItem
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
from ...core.co4e import SubAgent
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import ask_item, ask_multiline, ask_text
|
||||
|
||||
|
||||
class _StepConfigActionsMixin:
|
||||
@@ -67,14 +68,12 @@ class _StepConfigActionsMixin:
|
||||
"""Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó)."""
|
||||
if self._step is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
if names:
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names, 0, True) # editable: can type a new one
|
||||
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names, 0, True) # editable: can type a new one
|
||||
else:
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
|
||||
name, ok = ask_text(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
@@ -89,13 +88,11 @@ class _StepConfigActionsMixin:
|
||||
row = self.sub_list.row(item)
|
||||
if not (0 <= row < len(self._step.sub_agents)):
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
cur = self._step.sub_agents[row].agent
|
||||
start = names.index(cur) if cur in names else 0
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names or [cur], start, True)
|
||||
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names or [cur], start, True)
|
||||
name = (name or "").strip()
|
||||
if ok and name:
|
||||
self._step.sub_agents[row].agent = name
|
||||
@@ -151,7 +148,7 @@ class _StepConfigActionsMixin:
|
||||
role = self.role_edit.text().strip()
|
||||
if not name and not role:
|
||||
return
|
||||
hint, ok = QInputDialog.getMultiLineText(
|
||||
hint, ok = ask_multiline(
|
||||
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
|
||||
if not ok:
|
||||
return
|
||||
|
||||
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...config import PROVIDER_LABELS
|
||||
from ...core.co4e import PERMISSION_PRESETS, STEP_DONE, STEP_RUNNING, Step
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr
|
||||
from ...ui.icons import icon, icon_picker_combo
|
||||
from .node_property_actions_mixin import _StepConfigActionsMixin
|
||||
from .step_config_section import _add_section
|
||||
@@ -79,30 +79,34 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
# (skills/files/sub-agents). No tabs/accordion: every group's border
|
||||
# and heading are what separate it from its neighbours, and all three
|
||||
# are on screen (or one scroll away) at once.
|
||||
form, _basic_card = _add_section(outer, tr("co4e.tab_basic"))
|
||||
form, _basic_card = _add_section(outer, "co4e.tab_basic")
|
||||
|
||||
self.label_edit = QLineEdit()
|
||||
self.label_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_label"), self.label_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit)
|
||||
|
||||
self.role_edit = QLineEdit()
|
||||
self.role_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_role"), self.role_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_role"), self.role_edit)
|
||||
|
||||
# Dropdown of every icon in the registry (Monitoring's Icon Management
|
||||
# set + built-ins), each row previewing its actual glyph — still
|
||||
# editable so a not-yet-added custom name can be typed directly.
|
||||
self.icon_edit = icon_picker_combo()
|
||||
self.icon_edit.lineEdit().setPlaceholderText(tr("co4e.f_icon_placeholder"))
|
||||
# Kept on self because the combo's line edit belongs to C++: a binding
|
||||
# holds its widget weakly, so with no owner on this side the Python
|
||||
# wrapper could be collected and the binding silently dropped.
|
||||
self._icon_line = self.icon_edit.lineEdit()
|
||||
bind_placeholder(self._icon_line, "co4e.f_icon_placeholder")
|
||||
self.icon_edit.currentTextChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_icon"), self.icon_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_icon"), self.icon_edit)
|
||||
|
||||
self.instructions_edit = QPlainTextEdit()
|
||||
self.instructions_edit.setMaximumHeight(120)
|
||||
self.instructions_edit.textChanged.connect(self._on_edit)
|
||||
self.gen_btn = QPushButton(tr("co4e.ai_draft"))
|
||||
self.gen_btn = bind_text(QPushButton(), "co4e.ai_draft")
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip"))
|
||||
bind_tip(self.gen_btn, "co4e.ai_draft_tooltip")
|
||||
self.gen_btn.setEnabled(ctx is not None)
|
||||
self.gen_btn.clicked.connect(self._ai_draft)
|
||||
instr_box = QWidget()
|
||||
@@ -110,17 +114,17 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
ib.setContentsMargins(0, 0, 0, 0)
|
||||
ib.addWidget(self.instructions_edit)
|
||||
ib.addWidget(self.gen_btn, alignment=Qt.AlignRight)
|
||||
form.addRow(tr("co4e.f_instructions"), instr_box)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_instructions"), instr_box)
|
||||
|
||||
# Extra context — free-text background/info fed to the step at run time
|
||||
# (in addition to instructions, attachments and upstream outputs).
|
||||
self.context_edit = QPlainTextEdit()
|
||||
self.context_edit.setMaximumHeight(90)
|
||||
self.context_edit.setPlaceholderText(tr("co4e.f_context_placeholder"))
|
||||
bind_placeholder(self.context_edit, "co4e.f_context_placeholder")
|
||||
self.context_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_context"), self.context_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit)
|
||||
|
||||
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
|
||||
form2, _model_card = _add_section(outer, "co4e.tab_model_perm")
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
@@ -128,48 +132,52 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
self.model_combo.editTextChanged.connect(self._on_edit)
|
||||
self.load_models_btn = QPushButton()
|
||||
self.load_models_btn.setIcon(icon("download"))
|
||||
self.load_models_btn.setToolTip(tr("co4e.load_models_tooltip"))
|
||||
bind_tip(self.load_models_btn, "co4e.load_models_tooltip")
|
||||
self.load_models_btn.clicked.connect(self._load_models)
|
||||
self.load_models_btn.setEnabled(ctx is not None)
|
||||
model_row.addWidget(self.model_combo, 1)
|
||||
model_row.addWidget(self.load_models_btn)
|
||||
mrow = QWidget(); mrow.setLayout(model_row)
|
||||
form2.addRow(tr("co4e.f_model"), mrow)
|
||||
form2.addRow(bind_text(QLabel(), "co4e.f_model"), mrow)
|
||||
|
||||
self.perm_combo = QComboBox()
|
||||
for preset in PERMISSION_PRESETS:
|
||||
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
|
||||
perm_keys = [f"co4e.perm.{preset}" for preset in PERMISSION_PRESETS]
|
||||
for preset, key in zip(PERMISSION_PRESETS, perm_keys):
|
||||
self.perm_combo.addItem(tr(key), preset)
|
||||
# Only the visible labels follow the language — the data column stays
|
||||
# the preset id that ``_on_edit`` persists onto the Step.
|
||||
bind_items(self.perm_combo, perm_keys)
|
||||
self.perm_combo.currentIndexChanged.connect(self._on_edit)
|
||||
form2.addRow(tr("co4e.f_permission"), self.perm_combo)
|
||||
form2.addRow(bind_text(QLabel(), "co4e.f_permission"), self.perm_combo)
|
||||
|
||||
verify_row = QHBoxLayout()
|
||||
self.verify_chk = QCheckBox(tr("co4e.f_self_verify"))
|
||||
self.verify_chk = bind_text(QCheckBox(), "co4e.f_self_verify")
|
||||
self.verify_chk.toggled.connect(self._on_edit)
|
||||
self.rounds_spin = QSpinBox()
|
||||
self.rounds_spin.setRange(1, 5)
|
||||
self.rounds_spin.valueChanged.connect(self._on_edit)
|
||||
verify_row.addWidget(self.verify_chk)
|
||||
verify_row.addWidget(QLabel(tr("co4e.f_verify_rounds")))
|
||||
verify_row.addWidget(bind_text(QLabel(), "co4e.f_verify_rounds"))
|
||||
verify_row.addWidget(self.rounds_spin)
|
||||
verify_row.addStretch(1)
|
||||
vrow = QWidget(); vrow.setLayout(verify_row)
|
||||
form2.addRow("", vrow)
|
||||
|
||||
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files"))
|
||||
form3, _skills_card = _add_section(outer, "co4e.tab_skills_files")
|
||||
|
||||
# Skills checklist (registry skills)
|
||||
self.skills_list = QListWidget()
|
||||
self.skills_list.setMaximumHeight(110)
|
||||
self.skills_list.itemChanged.connect(self._on_edit)
|
||||
form3.addRow(tr("co4e.f_skills"), self.skills_list)
|
||||
form3.addRow(bind_text(QLabel(), "co4e.f_skills"), self.skills_list)
|
||||
|
||||
# Attachments — files whose extracted text is fed to this step at run time.
|
||||
self.attach_list = QListWidget()
|
||||
self.attach_list.setMaximumHeight(80)
|
||||
self.attach_add_btn = QPushButton(tr("co4e.attach_add"))
|
||||
self.attach_add_btn = bind_text(QPushButton(), "co4e.attach_add")
|
||||
self.attach_add_btn.setIcon(icon("plus"))
|
||||
self.attach_add_btn.clicked.connect(self._add_attachment)
|
||||
self.attach_del_btn = QPushButton(tr("co4e.attach_remove"))
|
||||
self.attach_del_btn = bind_text(QPushButton(), "co4e.attach_remove")
|
||||
self.attach_del_btn.setIcon(icon("trash"))
|
||||
self.attach_del_btn.clicked.connect(self._del_attachment)
|
||||
att_btns = QHBoxLayout()
|
||||
@@ -177,7 +185,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
att_btns.addWidget(self.attach_del_btn)
|
||||
att_btns.addStretch(1)
|
||||
abtn = QWidget(); abtn.setLayout(att_btns)
|
||||
form3.addRow(tr("co4e.f_attachments"), self.attach_list)
|
||||
form3.addRow(bind_text(QLabel(), "co4e.f_attachments"), self.attach_list)
|
||||
form3.addRow("", abtn)
|
||||
|
||||
# Parallel sub-agents get their OWN section — same header style as
|
||||
@@ -185,14 +193,14 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
# Skills & Tệp, since it's really a distinct group, just one that
|
||||
# only applies to parallel-variant steps. load_step() hides the whole
|
||||
# card for a non-parallel step (see is_par below).
|
||||
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents"))
|
||||
form4, self._parallel_card = _add_section(outer, "co4e.f_subagents")
|
||||
self.sub_list = QListWidget()
|
||||
self.sub_list.setMaximumHeight(90)
|
||||
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
|
||||
self.sub_add_btn = QPushButton(tr("co4e.add_subagent"))
|
||||
self.sub_add_btn = bind_text(QPushButton(), "co4e.add_subagent")
|
||||
self.sub_add_btn.setIcon(icon("plus"))
|
||||
self.sub_add_btn.clicked.connect(self._add_subagent)
|
||||
self.sub_del_btn = QPushButton(tr("co4e.del_subagent"))
|
||||
self.sub_del_btn = bind_text(QPushButton(), "co4e.del_subagent")
|
||||
self.sub_del_btn.setIcon(icon("trash"))
|
||||
self.sub_del_btn.clicked.connect(self._del_subagent)
|
||||
sub_btns = QHBoxLayout()
|
||||
@@ -205,17 +213,17 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
|
||||
# Footer actions — one compact row (Run · Run from here · Delete),
|
||||
# kept below every section, not inside one of the cards.
|
||||
self.run_btn = QPushButton(tr("co4e.run"))
|
||||
self.run_btn = bind_text(QPushButton(), "co4e.run")
|
||||
self.run_btn.setIcon(icon("play"))
|
||||
self.run_btn.setToolTip(tr("co4e.run_this_step"))
|
||||
bind_tip(self.run_btn, "co4e.run_this_step")
|
||||
self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id))
|
||||
self.run_from_btn = QPushButton(tr("co4e.run_from_here"))
|
||||
self.run_from_btn.setToolTip(tr("co4e.run_from_here"))
|
||||
self.run_from_btn = bind_text(QPushButton(), "co4e.run_from_here")
|
||||
bind_tip(self.run_from_btn, "co4e.run_from_here")
|
||||
self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id))
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setObjectName("danger")
|
||||
self.del_btn.setToolTip(tr("co4e.delete_step"))
|
||||
bind_tip(self.del_btn, "co4e.delete_step")
|
||||
self.del_btn.setFixedWidth(38)
|
||||
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
|
||||
foot = QHBoxLayout()
|
||||
|
||||
@@ -29,7 +29,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_text, bind_tip
|
||||
from .palette_list import _PaletteList
|
||||
|
||||
|
||||
@@ -50,8 +50,10 @@ class SkillsListPanel(QWidget):
|
||||
cái gì.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.manage_btn = QPushButton(tr("co4e.manage_skills"))
|
||||
self.manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
|
||||
# Bound, like AgentListPanel's: the panel owns how its own button reads,
|
||||
# so no embedder has to remember it in a retranslate method.
|
||||
self.manage_btn = bind_text(QPushButton(), "co4e.manage_skills")
|
||||
bind_tip(self.manage_btn, "co4e.tt_manage_skills")
|
||||
self.manage_btn.setObjectName("co4eSectionAction")
|
||||
self.manage_btn.setFlat(True)
|
||||
self.manage_btn.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
@@ -26,6 +26,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
|
||||
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import bind_dynamic, tr
|
||||
from ...theme import current_palette
|
||||
|
||||
_SECTION_ANIM_MS = 180
|
||||
@@ -64,7 +65,7 @@ class _SectionHeader(QLabel):
|
||||
super().showEvent(event)
|
||||
|
||||
|
||||
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
def _add_section(outer: QVBoxLayout, title_key: str) -> tuple[QFormLayout, QWidget]:
|
||||
"""One group of fields, collapsed to just its heading by default and
|
||||
independently expandable, so a long step config reads as a short list of
|
||||
group names until you open the one you need. Deliberately bare — no card
|
||||
@@ -74,7 +75,11 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
the group's rows to ``form``; ``card`` is the whole section (header +
|
||||
body) — hide it to remove the group entirely (e.g. for a section that
|
||||
only applies to some steps), rather than hiding individual rows inside
|
||||
an always-visible header."""
|
||||
an always-visible header.
|
||||
|
||||
Nhận KHOÁ dịch, không nhận chuỗi đã dịch: nhãn mục do hàm này tự dựng nên
|
||||
nơi gọi không giữ tham chiếu nào để áp lại: truyền ``tr(...)`` vào đây thì
|
||||
bốn tiêu đề đứng nguyên ở ngôn ngữ lúc dựng panel."""
|
||||
p = current_palette()
|
||||
card = QWidget()
|
||||
card_lay = QVBoxLayout(card)
|
||||
@@ -96,7 +101,6 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
# diacritics.
|
||||
header.ensurePolished()
|
||||
header.setFixedHeight(header.fontMetrics().height())
|
||||
header.setText(f"▶ {title}")
|
||||
card_lay.addWidget(header)
|
||||
|
||||
body = QWidget()
|
||||
@@ -112,6 +116,14 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
|
||||
is_open = False
|
||||
|
||||
def _sync_header() -> None:
|
||||
"""Nhãn mục: dấu gập/mở hiện tại + tiêu đề theo ngôn ngữ đang chọn."""
|
||||
header.setText(f"{'▼' if is_open else '▶'} {tr(title_key)}")
|
||||
|
||||
# Ràng buộc ĐỘNG chứ không bind cứng một chuỗi: nhãn này mang cả trạng thái
|
||||
# gập/mở, nên bind cứng sẽ trả nó về ▶ mỗi lần người dùng đổi ngôn ngữ.
|
||||
bind_dynamic(header, _sync_header)
|
||||
|
||||
def _on_finished() -> None:
|
||||
"""Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều
|
||||
trường không bị cắt cụt.
|
||||
@@ -130,7 +142,7 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
"""Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng."""
|
||||
nonlocal is_open
|
||||
is_open = not is_open
|
||||
header.setText(f"{'▼' if is_open else '▶'} {title}")
|
||||
_sync_header()
|
||||
anim.stop()
|
||||
if is_open:
|
||||
body.setVisible(True)
|
||||
|
||||
@@ -116,9 +116,9 @@ class HabitsWidget(QWidget):
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user
|
||||
approves: turn on auto-compress and compress earlier (lower
|
||||
threshold) + compress content before sending it to the agent."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
from ...ui.dialog_buttons import confirm
|
||||
if not confirm(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")):
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
|
||||
@@ -121,6 +121,13 @@ class UsageChartWidget(QWidget):
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip."""
|
||||
# setItemText, chứ không clear()+addItem(): cột data của hai combo này
|
||||
# là thứ quyết định kỳ và chỉ số đang xem, dựng lại danh sách sẽ reset cả
|
||||
# hai. Khoá dịch suy ra từ chính cột data nên không phải chép lại danh
|
||||
# sách giá trị ở hai nơi.
|
||||
for combo, prefix in ((self.gran_combo, "gran"), (self.metric_combo, "metric")):
|
||||
for i in range(combo.count()):
|
||||
combo.setItemText(i, tr(f"dashboard.{prefix}_{combo.itemData(i)}"))
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
|
||||
@@ -290,9 +290,9 @@ class AiEditPipeline:
|
||||
self.pending = None
|
||||
self._owner.show_confirm_row(False)
|
||||
if p.get("image_gens"):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
|
||||
from ...ui.dialog_buttons import confirm
|
||||
if not confirm(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")):
|
||||
self._owner.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return
|
||||
self._generate_then_finalize(p)
|
||||
|
||||
@@ -34,6 +34,9 @@ class FolderTab(QWidget):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._root = str(ctx.config.cowork_output_dir())
|
||||
# Goc ma project dang chon ap xuong, de phan biet "doi project" voi
|
||||
# "chi la refresh" — xem set_project_root().
|
||||
self._project_root = ""
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
split = QSplitter(Qt.Horizontal)
|
||||
@@ -82,6 +85,10 @@ class FolderTab(QWidget):
|
||||
self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root))
|
||||
root_layout.addWidget(self.terminal)
|
||||
|
||||
# ``root_changed`` truoc day KHONG co ai lang nghe: nguoi dung tu chon
|
||||
# thu muc trong cay thi chi cai cay doi goc, con khung xem va terminal o
|
||||
# lai thu muc cu.
|
||||
self.tree.root_changed.connect(self._on_user_picked_root)
|
||||
self.tree.file_selected.connect(self.preview.open_file)
|
||||
self.preview.status_message.connect(self.status_message.emit)
|
||||
self.ai_panel.status_message.connect(self.status_message.emit)
|
||||
@@ -105,6 +112,29 @@ class FolderTab(QWidget):
|
||||
self.preview.set_root(path)
|
||||
self.terminal.set_cwd(path)
|
||||
|
||||
def _on_user_picked_root(self, path: str) -> None:
|
||||
"""Người dùng tự chọn thư mục trong cây: lan sang khung xem và terminal."""
|
||||
self._root = path
|
||||
self.preview.set_root(path)
|
||||
self.terminal.set_cwd(path)
|
||||
|
||||
def set_project_root(self, path: str) -> None:
|
||||
"""Áp thư mục gốc theo project đang chọn.
|
||||
|
||||
Bỏ qua nếu project KHÔNG đổi. ``WorkspaceTab.refresh()`` — và qua đó
|
||||
``_load_current`` — chạy lại mỗi lần người dùng vào lại màn Workspace
|
||||
(``_goto`` gọi nó), nên gọi ``set_root`` vô điều kiện sẽ kéo thư mục về
|
||||
workspace của project và xoá mất lựa chọn tay: chọn folder khác, chuyển
|
||||
tab rồi quay lại là mất.
|
||||
|
||||
Đổi sang project khác thì vẫn re-root — thư mục của màn này thuộc về
|
||||
project, chỉ có lần refresh trong CÙNG một project là không được đụng.
|
||||
"""
|
||||
if path == self._project_root:
|
||||
return
|
||||
self._project_root = path
|
||||
self.set_root(path)
|
||||
|
||||
def _toggle_ai_panel(self) -> None:
|
||||
"""Gập/mở panel AI-Edit; mở ra thì báo cho panel biết để nó nạp model lần đầu."""
|
||||
show = self.ai_btn.isChecked()
|
||||
|
||||
@@ -276,10 +276,9 @@ class OfficeDocumentRenderer:
|
||||
from cowork_local.core import pptx_edit
|
||||
o = self._owner
|
||||
if not skip_confirm and pptx_edit.image_change_requested(content):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm"))
|
||||
if ok != QMessageBox.Yes:
|
||||
from cowork_local.ui.dialog_buttons import confirm
|
||||
if not confirm(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm")):
|
||||
o.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return False
|
||||
pptx_edit.apply_text_to_pptx(o.current_file, content)
|
||||
|
||||
@@ -202,6 +202,14 @@ class GraphRenderer(QWidget):
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
|
||||
@@ -9,9 +9,11 @@ inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QProgressBar, QSplitter, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import on_language_changed
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
from cowork_local.state import AppContext
|
||||
@@ -35,6 +37,9 @@ class StructureGraphView(QWidget):
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
# Project ma man Workspace da ap xuong lan gan nhat. None = chua ap lan
|
||||
# nao, de lan goi dau tien khong bi bo qua ke ca khi pid la chuoi rong.
|
||||
self._workspace_project = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
@@ -50,6 +55,7 @@ class StructureGraphView(QWidget):
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
root.addWidget(self._split, 1)
|
||||
|
||||
self._build_busy_panel()
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
@@ -72,18 +78,143 @@ class StructureGraphView(QWidget):
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
|
||||
# ---- public API (app.py / ui/workspace_tab.py) --------------------------- #
|
||||
# ---- panel "đang tải" ---------------------------------------------------
|
||||
def _build_busy_panel(self) -> None:
|
||||
"""Panel phủ lên khung đồ thị trong lúc nó đang được dựng.
|
||||
|
||||
Vì sao cần: ``prewarm()`` chỉ chạy 3 giây sau khi cửa sổ hiện
|
||||
(``main_window.py``), nên người dùng bấm GraphRAG trong 3 giây đầu sẽ
|
||||
gặp ``_ensure_web()`` dựng ``QWebEngineView`` ĐỒNG BỘ trên GUI thread —
|
||||
đóng băng 1-2 giây mà trước đây không có gì báo.
|
||||
"""
|
||||
self._busy = QWidget(self)
|
||||
self._busy.setObjectName("graphBusy")
|
||||
lay = QHBoxLayout(self._busy)
|
||||
lay.setContentsMargins(18, 14, 18, 14)
|
||||
lay.setSpacing(12)
|
||||
self._busy_label = QLabel()
|
||||
bar = QProgressBar()
|
||||
bar.setRange(0, 0) # chế độ vô định
|
||||
bar.setTextVisible(False)
|
||||
bar.setFixedWidth(120)
|
||||
lay.addWidget(self._busy_label)
|
||||
lay.addWidget(bar)
|
||||
self._busy.hide()
|
||||
self.renderer.graph_rendered.connect(self._hide_busy)
|
||||
# Đổi project ngay trên màn này thì renderer quét luôn, không chờ lần ghé
|
||||
# sau — nên panel phải bật theo, không thì lượt quét đó lại im lặng.
|
||||
self.renderer.project_changed.connect(self._on_project_scan_started)
|
||||
|
||||
def _ensure_scan_path(self) -> None:
|
||||
"""Điền ô đường dẫn từ project đang hoạt động nếu nó đang rỗng.
|
||||
|
||||
``GraphRenderer.auto_scan_and_fit`` thoát sớm khi ô đường dẫn rỗng,
|
||||
trong khi chính nút Scan lại có đường lùi (``path_edit.text() or
|
||||
Path.cwd()``). Bất đối xứng đó nghĩa là: vào màn thì không làm gì, bấm
|
||||
Scan thì chạy — đúng thứ người dùng phàn nàn.
|
||||
|
||||
Điền từ project đang chọn chứ KHÔNG lấy ``cwd()`` làm đường lùi như nút
|
||||
Scan: quét thư mục làm việc của tiến trình là quét một cây không liên
|
||||
quan gì tới project, và nó có thể rất lớn.
|
||||
"""
|
||||
if self.renderer.path_edit.text().strip():
|
||||
return
|
||||
ctx = getattr(self, "ctx", None)
|
||||
pid = (getattr(ctx, "active_project_id", "") or "").strip()
|
||||
if not pid or pid == "default":
|
||||
return
|
||||
try:
|
||||
from ...core.projects import load_project
|
||||
project = load_project(pid)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
if project is not None:
|
||||
self.renderer.path_edit.setText(str(project.workspace_dir()))
|
||||
|
||||
def _on_project_scan_started(self) -> None:
|
||||
"""Renderer vừa đổi project. Nó chỉ quét ngay khi màn này đang mở."""
|
||||
if self.renderer.isVisible():
|
||||
self._show_busy("structure.scanning")
|
||||
|
||||
def _show_busy(self, key: str) -> None:
|
||||
"""Hiện panel và ÉP VẼ NGAY.
|
||||
|
||||
``repaint()`` chứ không ``update()``: ``update()`` chỉ xếp hàng một lượt
|
||||
vẽ cho vòng lặp sự kiện, mà vòng lặp đó sắp bị chặn — panel sẽ chỉ hiện
|
||||
ra SAU khi hết đóng băng, đúng lúc không còn cần tới nó nữa.
|
||||
"""
|
||||
self._busy_label.setText(tr(key))
|
||||
self._busy.adjustSize()
|
||||
self._center_busy()
|
||||
self._busy.show()
|
||||
self._busy.raise_()
|
||||
self._busy.repaint()
|
||||
|
||||
def _hide_busy(self) -> None:
|
||||
"""Ẩn panel khi đồ thị đã vẽ xong."""
|
||||
if getattr(self, "_busy", None) is not None:
|
||||
self._busy.hide()
|
||||
|
||||
def _center_busy(self) -> None:
|
||||
"""Giữ panel ở giữa khung, kể cả khi cửa sổ đổi kích thước."""
|
||||
busy = getattr(self, "_busy", None)
|
||||
if busy is None:
|
||||
return
|
||||
size = busy.sizeHint()
|
||||
busy.setGeometry((self.width() - size.width()) // 2,
|
||||
(self.height() - size.height()) // 2,
|
||||
size.width(), size.height())
|
||||
|
||||
def resizeEvent(self, e): # noqa: N802
|
||||
"""Đổi kích thước cửa sổ thì panel phải theo."""
|
||||
super().resizeEvent(e)
|
||||
self._center_busy()
|
||||
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
"""Hẹn quét lại đồ thị sau khi thư mục có thay đổi."""
|
||||
self.renderer.schedule_rescan(path)
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
"""Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần."""
|
||||
self.renderer.auto_scan_and_fit()
|
||||
"""Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần.
|
||||
|
||||
Panel "đang tải" bật TRƯỚC khi gọi xuống renderer: phần dựng khung xem
|
||||
chặn GUI thread, nên phải vẽ panel ra trước lúc đó. Nó tự ẩn khi
|
||||
``graph_rendered`` phát — bao trọn cả lượt quét chạy ở luồng nền phía
|
||||
sau, chứ không tắt ngay khi hàm này trả về.
|
||||
"""
|
||||
self._ensure_scan_path()
|
||||
da_dung_khung = self.renderer.web is not None
|
||||
self._show_busy("structure.scanning" if da_dung_khung else "structure.loading_view")
|
||||
try:
|
||||
self.renderer.auto_scan_and_fit()
|
||||
finally:
|
||||
# Khong co gi de ve (chua chon thu muc) thi graph_rendered khong phat
|
||||
# — panel se treo lai mai mai neu khong tu don o day.
|
||||
if not self.renderer.path_edit.text().strip():
|
||||
self._hide_busy()
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
self.renderer.set_project(project_id)
|
||||
|
||||
def set_workspace_project(self, project_id: str) -> None:
|
||||
"""Áp project theo màn Workspace — bỏ qua nếu project KHÔNG đổi.
|
||||
|
||||
``WorkspaceTab._bind_project`` gọi xuống đây, và nó chạy lại mỗi lần
|
||||
người dùng vào lại màn Workspace (``_goto`` -> ``refresh`` ->
|
||||
``_load_current`` -> ``_bind_project``). Áp vô điều kiện thì bộ chọn
|
||||
project của chính màn GraphRAG bị kéo về giá trị của Workspace: chọn một
|
||||
project ở đây, sang tab khác rồi quay lại là mất.
|
||||
|
||||
Đổi sang project khác ở màn Workspace thì vẫn áp — cùng luật với tab Thư
|
||||
mục (``FolderTab.set_project_root``). Chỉ lần refresh trong CÙNG một
|
||||
project là không được đụng.
|
||||
"""
|
||||
if project_id == self._workspace_project:
|
||||
return
|
||||
self._workspace_project = project_id
|
||||
self.set_project(project_id)
|
||||
|
||||
def prewarm(self) -> None:
|
||||
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
|
||||
self.renderer.prewarm()
|
||||
|
||||
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
|
||||
QSplitter, QTableWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....i18n import tr
|
||||
from ....i18n import bind_tip, tr
|
||||
from ....ui.icons import icon
|
||||
from .event_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable
|
||||
from .event_detail_panel import EventDetailPanel
|
||||
@@ -83,7 +83,10 @@ def build_filter_scaffold(
|
||||
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"))
|
||||
# Bound rather than set once: this scaffold builds the button for all
|
||||
# three event tabs, and none of their retranslate() methods can reach a
|
||||
# tooltip that was applied here.
|
||||
bind_tip(ai_btn, "monitoring.ai_filter_tooltip")
|
||||
ai_btn.setCursor(Qt.PointingHandCursor)
|
||||
if on_ai_filter is not None:
|
||||
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
|
||||
|
||||
@@ -20,6 +20,7 @@ from ....core import admin_agents, preview_ai
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.dialog_buttons import dialog_buttons
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import ToggleSwitch, badge_pill_widget
|
||||
|
||||
@@ -95,7 +96,7 @@ class AgentEditDialog(QDialog):
|
||||
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
|
||||
self.enabled_chk.setChecked(agent.enabled if agent else True)
|
||||
form.addRow("", self.enabled_chk)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
@@ -26,7 +26,7 @@ from typing import Dict, List
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
|
||||
QHBoxLayout, QHeaderView, QLabel, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ from ....core import admin_agents
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.dialog_buttons import confirm
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import ToggleSwitch, badge_pill_widget
|
||||
from .agent_edit_dialog import AgentEditDialog
|
||||
@@ -189,9 +190,8 @@ class AgentsAdminTab(QWidget):
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
if QMessageBox.question(
|
||||
self, tr("agents_admin.delete_title"),
|
||||
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
|
||||
if not confirm(self, tr("agents_admin.delete_title"),
|
||||
tr("agents_admin.delete_confirm", name=agent.name)):
|
||||
return
|
||||
admin_agents.delete_agent(agent.agent_id, self._dir())
|
||||
self.refresh()
|
||||
|
||||
@@ -139,11 +139,11 @@ class PricingPanel(QGroupBox):
|
||||
|
||||
def _add_pricing_row(self) -> None:
|
||||
"""Thêm một dòng đơn giá trống để người dùng điền tay."""
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
from ....ui.dialog_buttons import ask_text
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
|
||||
tr("monitoring.pricing_add_prompt"))
|
||||
name, ok = ask_text(self, tr("monitoring.pricing_add"),
|
||||
tr("monitoring.pricing_add_prompt"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
@@ -179,9 +179,12 @@ class ToolsAdminTab(QWidget):
|
||||
hdr.addWidget(sw)
|
||||
lay.addLayout(hdr)
|
||||
|
||||
desc = QLabel(spec.description)
|
||||
# spec.description là mô tả gửi cho mô hình (schema function-calling),
|
||||
# luôn tiếng Anh và viết cho máy đọc — thẻ này dùng bản dịch riêng.
|
||||
desc_text = tr(f"tools_admin.desc.{spec.name}")
|
||||
desc = QLabel(desc_text)
|
||||
desc.setWordWrap(True)
|
||||
desc.setToolTip(spec.description)
|
||||
desc.setToolTip(desc_text)
|
||||
desc.setObjectName("hint")
|
||||
desc.setStyleSheet("border: none;")
|
||||
lay.addWidget(desc)
|
||||
|
||||
@@ -32,6 +32,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.dialog_buttons import dialog_buttons
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
@@ -79,8 +80,8 @@ class AiTaskCreatorDialog(QDialog):
|
||||
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
|
||||
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
|
||||
ok="schedtask.ai_confirm")
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.buttons.accepted.connect(self._confirm)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
|
||||
@@ -38,6 +38,7 @@ from cowork_local.infrastructure.persistence.json.task_repository_impl import (
|
||||
)
|
||||
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.dialog_buttons import confirm
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
||||
@@ -317,9 +318,8 @@ class KanbanBoardWidget(QWidget):
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
if confirm(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))):
|
||||
self._service.delete(tid)
|
||||
self.refresh()
|
||||
|
||||
@@ -335,9 +335,8 @@ class KanbanBoardWidget(QWidget):
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
if not confirm(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))):
|
||||
return False
|
||||
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
|
||||
self._service.bulk_delete(ids)
|
||||
@@ -381,7 +380,7 @@ class KanbanBoardWidget(QWidget):
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
|
||||
return
|
||||
self._repo.save(nxt)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
|
||||
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.dialog_buttons import dialog_buttons
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
@@ -53,7 +54,7 @@ class RunHistoryDialog(QDialog):
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons = dialog_buttons(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Mục "Giới thiệu" trong Cài đặt.
|
||||
|
||||
Dòng ghi công tác giả trước đây nằm cố định ở góc dưới phải cửa sổ, dưới dạng
|
||||
widget thường trực của thanh trạng thái. Chỗ đó chiếm một góc màn hình trên MỌI
|
||||
màn hình, suốt cả phiên làm việc, cho một thông tin người dùng chỉ cần đọc một
|
||||
lần. Chuyển vào Cài đặt: vẫn tra được bất cứ lúc nào, nhưng không còn đứng
|
||||
thường trực trong tầm mắt.
|
||||
|
||||
Cùng nhóm widget với bốn mục kia của Cài đặt (``general``/``provider``/
|
||||
``parameter``/``routing``) nên nó tự là một trang, không cần lắp ráp riêng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from ... import DISPLAY_NAME, __version__
|
||||
from ...i18n import tr
|
||||
|
||||
|
||||
class AboutSettingsWidget(QWidget):
|
||||
"""Nhóm "Giới thiệu": tên ứng dụng và dòng ghi công tác giả."""
|
||||
|
||||
def __init__(self, ctx=None, parent: QWidget | None = None) -> None:
|
||||
"""Trang Giới thiệu. ``ctx`` không dùng tới, giữ cho khớp chữ ký của
|
||||
bốn widget Cài đặt còn lại."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Tên sản phẩm là danh từ riêng, không dịch — lấy thẳng từ gốc gói,
|
||||
# cùng nguồn với tiêu đề cửa sổ (main_window.py:90) nên hai chỗ không lệch.
|
||||
self.app_label = QLabel(f"{DISPLAY_NAME} v{__version__}")
|
||||
# Không đặt màu ở đây: cỡ chữ là khác biệt duy nhất cần thiết, còn màu
|
||||
# do theme quyết định (xem theme/__init__.py — ngoài theme/ không file
|
||||
# nào được đặt tên một màu).
|
||||
font = self.app_label.font()
|
||||
font.setPointSize(font.pointSize() + 4)
|
||||
font.setBold(True)
|
||||
self.app_label.setFont(font)
|
||||
layout.addWidget(self.app_label)
|
||||
|
||||
self.version_label = QLabel(tr("app.version", v=__version__))
|
||||
self.version_label.setObjectName("faint")
|
||||
self.version_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
layout.addWidget(self.version_label)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
def apply_to(self, data: dict) -> None:
|
||||
"""Không có thiết lập nào để ghi — mục này chỉ hiển thị.
|
||||
|
||||
Vẫn khai để khớp giao diện chung của các trang Cài đặt: ``_save`` gọi
|
||||
``apply_to`` trên từng trang, nên một trang thiếu hàm này sẽ là
|
||||
``AttributeError`` ngay lần đầu ai đó thêm nó vào vòng lặp.
|
||||
"""
|
||||
return
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Lớp phủ "đang xử lý" ở cấp cửa sổ, dành cho tác vụ chặn GUI thread.
|
||||
|
||||
Vì sao là file riêng chứ không nhét vào ``main_window.py``: file đó chỉ còn 9
|
||||
dòng vật lý dưới trần 400 của Gate S, và một lớp phủ cấp cửa sổ là một trách
|
||||
nhiệm riêng (guardrail G6).
|
||||
|
||||
Cùng lý do ``repaint()`` với panel bận của GraphRAG — xem
|
||||
``presentation/graph/structure_graph_view.py:139-151``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from time import perf_counter
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
# Dưới mức này người dùng chưa kịp nhận ra mình đang đợi, nên một lớp phủ toàn
|
||||
# cửa sổ chỉ kịp nháy lên rồi tắt — tự nó là một khuyết tật giao diện, không
|
||||
# phải một lời trấn an.
|
||||
_NOTICEABLE_MS = 400.0
|
||||
|
||||
|
||||
class BusyOverlay(QWidget):
|
||||
"""A window-wide "please wait" cover for work that blocks the GUI thread.
|
||||
|
||||
Deliberately NOT registered with :func:`i18n.on_language_changed`: the text
|
||||
is supplied by the caller right before the block and must stay in the
|
||||
language the rest of the screen is still showing.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget) -> None:
|
||||
"""Build the cover hidden; it sizes itself to the parent on every show."""
|
||||
super().__init__(parent)
|
||||
self.setObjectName("busyOverlay")
|
||||
# A QWidget SUBCLASS ignores a stylesheet background without this
|
||||
# attribute; a plain QWidget instance (the panel below) does not need it.
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setFocusPolicy(Qt.NoFocus)
|
||||
# Chưa đo được lượt nào: xem mục ``run_blocking``.
|
||||
self._last_ms: float | None = None
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addStretch(1)
|
||||
row = QHBoxLayout()
|
||||
row.addStretch(1)
|
||||
self._panel = QWidget()
|
||||
self._panel.setObjectName("busyOverlayPanel")
|
||||
inner = QHBoxLayout(self._panel)
|
||||
inner.setContentsMargins(24, 18, 24, 18)
|
||||
self._label = QLabel()
|
||||
inner.addWidget(self._label)
|
||||
row.addWidget(self._panel)
|
||||
row.addStretch(1)
|
||||
lay.addLayout(row)
|
||||
lay.addStretch(1)
|
||||
self.hide()
|
||||
|
||||
def text(self) -> str:
|
||||
"""Chữ đang hiện trên lớp phủ (dùng cho test)."""
|
||||
return self._label.text()
|
||||
|
||||
def run_blocking(self, message: str, work: Callable[[], None]) -> None:
|
||||
"""Run ``work`` on the GUI thread, covered only when that is worth doing.
|
||||
|
||||
Nothing can time the freeze WHILE it happens: the GUI thread stops, so
|
||||
no timer fires and no watchdog can raise the cover mid-way. The only
|
||||
honest clock is the PREVIOUS run of this same call, so that is what
|
||||
decides. No measurement yet (the first switch of a process) errs
|
||||
towards showing: one flash is a smaller defect than a multi-second
|
||||
freeze with nothing on screen to explain it.
|
||||
|
||||
The result is self-calibrating. A fast machine flashes once per launch
|
||||
and then stays out of the way; a slow one, or a big skill library, gets
|
||||
the cover on every switch from the second one on.
|
||||
|
||||
``work`` is timed and its exceptions propagate — the cover still comes
|
||||
down, so a raising callback cannot leave it stuck on screen forever.
|
||||
"""
|
||||
if self._last_ms is None or self._last_ms >= _NOTICEABLE_MS:
|
||||
self.show_busy(message)
|
||||
started = perf_counter()
|
||||
try:
|
||||
work()
|
||||
finally:
|
||||
self._last_ms = (perf_counter() - started) * 1000.0
|
||||
self.hide_busy()
|
||||
|
||||
def show_busy(self, message: str) -> None:
|
||||
"""Show the cover and FORCE it onto the screen right now.
|
||||
|
||||
``repaint()``, not ``update()``: the caller is about to block the GUI
|
||||
thread, so a queued paint would only run once the freeze is over — the
|
||||
one moment the cover is no longer needed.
|
||||
|
||||
No animated progress bar on purpose: with no event loop running,
|
||||
nothing would move; only static text is guaranteed to be readable.
|
||||
"""
|
||||
self._label.setText(message)
|
||||
self.setGeometry(self.parent().rect())
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.repaint()
|
||||
|
||||
def hide_busy(self) -> None:
|
||||
"""Release the cover. Call from ``finally`` so a raising callback
|
||||
cannot leave it stuck on screen forever."""
|
||||
self.hide()
|
||||
@@ -163,9 +163,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
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).
|
||||
# Land on the Workspace screen. 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). Which Workspace sub-view the user
|
||||
# ends up looking at is settled after _restore_sessions(), below.
|
||||
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
|
||||
@@ -176,14 +177,19 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
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)
|
||||
# Góc dưới phải: đúng một dòng phiên bản (cùng nguồn với tiêu đề cửa sổ).
|
||||
# Tắt size grip — nó vẽ một vệt ngay bên phải chữ; cửa sổ vẫn kéo được cạnh.
|
||||
self.statusBar().setSizeGripEnabled(False)
|
||||
self.statusBar().addPermanentWidget(QLabel(tr("app.version", v=__version__)))
|
||||
self._restore_sessions()
|
||||
# Open on "All projects…" — literally the same call the nav rail's link
|
||||
# of that name makes, so the rail highlight and the content can never
|
||||
# disagree. This runs AFTER the restore on purpose: _restore_sessions
|
||||
# still reloads the last thread, because recovering it after a crash is
|
||||
# the whole point of it, but the user should first see the list of
|
||||
# everything rather than whichever conversation happened to be open when
|
||||
# the app was last closed.
|
||||
self.goto_all_projects()
|
||||
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).
|
||||
@@ -252,9 +258,14 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
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"))
|
||||
# The label is hidden — the combo names itself through its tooltip
|
||||
# (see top_bar._build_account_row), so that is the one users read.
|
||||
self.provider_combo.setToolTip(tr("app.provider"))
|
||||
if hasattr(self, "nav_project"):
|
||||
self.nav_project.setToolTip(tr("app.nav.project_pick"))
|
||||
self.nav_recents_hdr.setText(tr("app.nav.recents"))
|
||||
if hasattr(self, "settings_btn"):
|
||||
self.settings_btn.setText(tr("app.settings"))
|
||||
if hasattr(self, "theme_btn"):
|
||||
@@ -266,6 +277,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self.help_agent.retranslate()
|
||||
self._tray.retranslate()
|
||||
# Thanh trạng thái (góc dưới bên trái) nhận thông báo từ hàng chục nơi
|
||||
# qua signal ``status_message``, và signal đó mang CHUỖI ĐÃ DỊCH chứ
|
||||
# không mang khoá — nên không thể dịch lại câu đang hiện. Đưa nó về câu
|
||||
# nền của ngôn ngữ mới: câu cũ không đọng lại bằng thứ tiếng vừa rời đi,
|
||||
# mà chỗ đó cũng không trống trơn. Thông báo là ghi chú về một việc vừa
|
||||
# xong, nên bỏ nó đi khi đổi ngôn ngữ không làm mất thông tin nào.
|
||||
self.statusBar().showMessage(tr("app.status.ready"))
|
||||
|
||||
# ---- system tray (run in background when the window is closed) ---
|
||||
|
||||
|
||||
@@ -163,6 +163,12 @@ class NavRailMixin:
|
||||
self.split = QSplitter(Qt.Horizontal)
|
||||
self.split.addWidget(self._nav_wrap)
|
||||
self.split.addWidget(right)
|
||||
# A QSplitter lets the user drag a pane past its own minimumWidth and
|
||||
# snap it shut at zero — the minimum only governs LAYOUT, not the drag.
|
||||
# So the rail set a 132px floor and still vanished entirely when dragged
|
||||
# hard left, with no handle left wide enough to notice, let alone grab.
|
||||
# Collapsing is what the MENU button is for, and that stops at 54px.
|
||||
self.split.setChildrenCollapsible(False)
|
||||
self.split.setStretchFactor(0, 0)
|
||||
self.split.setStretchFactor(1, 1)
|
||||
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
|
||||
@@ -245,12 +251,19 @@ class NavRailMixin:
|
||||
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.
|
||||
# Rows plus frame, nothing else: the flat ``+ 8`` this replaces
|
||||
# left 6px of dead space under the last row, and since Settings
|
||||
# sits directly under nav_bottom it fell between Giám sát and
|
||||
# Settings alone — 18/26px for rows that read as one list.
|
||||
row_h = 0
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
n = tree.topLevelItemCount()
|
||||
row_h = tree.sizeHintForRow(0) if n else 0
|
||||
tree.setFixedHeight(n * row_h + 8)
|
||||
row_h = tree.sizeHintForRow(0) if n else row_h
|
||||
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
|
||||
# Settings is one more row of the list, so it takes the rows' own
|
||||
# height instead of paddings guessed to match it.
|
||||
if row_h and hasattr(self, "_nav_settings_btn"):
|
||||
self._nav_settings_btn.setFixedHeight(row_h)
|
||||
if keep:
|
||||
self._select_nav_row(*keep)
|
||||
finally:
|
||||
|
||||
@@ -47,6 +47,60 @@ class PageRegistryMixin:
|
||||
m.status_message.connect(self.statusBar().showMessage)
|
||||
self.monitoring = m
|
||||
return m
|
||||
def current_screen_label(self) -> str:
|
||||
"""Tên màn hình người dùng đang mở, dạng "Workspace ▸ Project".
|
||||
|
||||
Trợ lý Hỗ trợ không nhìn được màn hình, nên "tôi đang ở đâu" là câu nó
|
||||
không thể tự trả lời — chuỗi này được ghép vào prompt của nó
|
||||
(``core/help_knowledge.py``).
|
||||
"""
|
||||
row = self.pages.currentIndex()
|
||||
if not (0 <= row < len(self._nav_defs)):
|
||||
return ""
|
||||
page = tr(self._nav_defs[row][0])
|
||||
if row != self._ROW_WORKSPACE:
|
||||
return page
|
||||
sub = self.workspace.current_subtab()
|
||||
keys = {self.workspace._project_tab_idx: "workspace.tab_project",
|
||||
self.workspace._cowork_tab_idx: "workspace.tab_cowork",
|
||||
self.workspace._co4e_tab_idx: "workspace.tab_co4e",
|
||||
getattr(self.workspace, "_folder_tab_idx", -1): "workspace.tab_folder",
|
||||
self.workspace._graphrag_tab_idx: "workspace.tab_graphrag"}
|
||||
key = keys.get(sub)
|
||||
return f"{page} ▸ {tr(key)}" if key else page
|
||||
|
||||
def help_context(self) -> str:
|
||||
"""Ngữ cảnh sống cho Trợ lý Hỗ trợ: đang ở màn nào, và trên đó bấm được gì.
|
||||
|
||||
Vì sao đọc từ WIDGET ĐANG CHẠY chứ không từ ``docs/screens/controls.json``:
|
||||
file đó được trích tự động nhưng đã cũ — 5/41 file trong đó không còn tồn
|
||||
tại, và nó không có file nào trong ``presentation/`` (chưa sinh lại sau
|
||||
refactor R08). Nạp nó vào prompt là dạy trợ lý về nút của những file đã
|
||||
bị xoá. Cây widget thật thì không bao giờ cũ được.
|
||||
"""
|
||||
from PySide6.QtWidgets import QPushButton, QTabWidget
|
||||
|
||||
from ...core.help_knowledge import screen_context
|
||||
|
||||
page = self.pages.currentWidget()
|
||||
nhan = []
|
||||
if page is not None:
|
||||
for tabs in page.findChildren(QTabWidget):
|
||||
for i in range(tabs.count()):
|
||||
if tabs.isTabVisible(i):
|
||||
nhan.append(f"[tab] {tabs.tabText(i)}")
|
||||
for btn in page.findChildren(QPushButton):
|
||||
text = (btn.text() or "").replace("&", "").strip()
|
||||
if text and btn.isVisible() and btn.isEnabled():
|
||||
nhan.append(text)
|
||||
# Bo trung, giu nguyen thu tu xuat hien tren man hinh.
|
||||
thay, gon = set(), []
|
||||
for n in nhan:
|
||||
if n not in thay:
|
||||
thay.add(n)
|
||||
gon.append(n)
|
||||
return screen_context(self.current_screen_label(), gon)
|
||||
|
||||
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]:
|
||||
|
||||
@@ -11,11 +11,20 @@ from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QStyledItemDelegate
|
||||
|
||||
# ---- kích thước ---------------------------------------------------------
|
||||
_NAV_EXPANDED_WIDTH = 150
|
||||
_NAV_EXPANDED_WIDTH = 232
|
||||
_NAV_COLLAPSED_WIDTH = 54
|
||||
_NAV_ROW_INSET = 8
|
||||
_NAV_ROW_GAP = 6
|
||||
_NAV_MIN_WIDTH = 132
|
||||
# Khe TRÊN nút Cài đặt, tính bằng khoảng trống thật trong layout của rail.
|
||||
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
|
||||
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
|
||||
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
|
||||
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không
|
||||
# rộng đến mức trông như hai khu tách rời.
|
||||
_NAV_SETTINGS_GAP = 4
|
||||
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
|
||||
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
|
||||
_NAV_MIN_WIDTH = 232
|
||||
_NAV_MAX_SHARE = 0.22
|
||||
_NAV_MAX_CEILING = 360
|
||||
|
||||
|
||||
@@ -114,12 +114,41 @@ class RailProjectMixin:
|
||||
tree.blockSignals(blocked)
|
||||
self.nav_recents_hdr.setVisible(not self._nav_collapsed)
|
||||
self.nav_recents.setVisible(not self._nav_collapsed)
|
||||
def goto_all_projects(self) -> None:
|
||||
"""Mở khung "Tất cả project…": Workspace ▸ Cowork, bảng lịch sử bung ra.
|
||||
|
||||
Phải đi qua ``_goto``, không được gọi thẳng ``show_history_pane()``:
|
||||
``_goto`` là chỗ duy nhất dời vệt sáng trên thanh menu theo nội dung.
|
||||
Gọi tắt thì nội dung sang Cowork còn vệt sáng ở lại Project — và vì
|
||||
``QTreeWidget`` không phát ``currentItemChanged`` khi bấm lại đúng dòng
|
||||
đang chọn, bấm "Project" sẽ KHÔNG có tác dụng gì cho tới khi người dùng
|
||||
bấm sang mục khác rồi bấm về.
|
||||
|
||||
Gom vào một hàm vì có hai đường vào: link "Tất cả project…" ở mục GẦN
|
||||
ĐÂY, và màn hình mặc định lúc mở app.
|
||||
"""
|
||||
sub = self.workspace._cowork_tab_idx
|
||||
# Chua chon project thi cong project dang giu sub-tab Cowork dong.
|
||||
# show_history_pane() mo no bang cua sau, bo qua cong — the la noi dung
|
||||
# o Cowork trong khi ca cong lan vet sang deu noi la Project. Man hinh
|
||||
# dung cho nguoi chua co project chinh la man quan ly project.
|
||||
if sub < 0 or not self.workspace.subtab_available(sub):
|
||||
self._goto(self._ROW_WORKSPACE, self.workspace._project_tab_idx)
|
||||
return
|
||||
self._goto(self._ROW_WORKSPACE, sub)
|
||||
self.workspace.show_history_pane()
|
||||
# Bang lich su duoc nhung trong sub-tab Cowork cua MOT project, nen
|
||||
# WorkspaceTab._bind_project da dat set_project_filter(pid) — no chi hien
|
||||
# thread cua project dang chon. Vao day bang link "Tat ca project…" ma van
|
||||
# con bo loc thi nguoi dung tao 5 project chi thay 1. Xoa bo loc SAU
|
||||
# show_history_pane(), vi ham do di qua duong doi sub-tab.
|
||||
self.sidebar.set_project_filter("")
|
||||
|
||||
def _on_rail_recent(self, item, _col: int = 0) -> None:
|
||||
"""Bấm một dòng trong mục GẦN ĐÂY: mở hội thoại đó, hoặc mở khung "Tất cả project…"."""
|
||||
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()
|
||||
self.goto_all_projects()
|
||||
return
|
||||
path = data.get("path")
|
||||
if path:
|
||||
|
||||
@@ -35,7 +35,7 @@ class TopBarMixin:
|
||||
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
|
||||
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP
|
||||
|
||||
# Bottom-pinned group: the places you visit occasionally, kept out of the
|
||||
# way of the ones you live in. A hairline (styled via #navrailBottom in
|
||||
@@ -54,7 +54,12 @@ class TopBarMixin:
|
||||
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._nav_settings_btn.clicked.connect(self._open_settings)
|
||||
srow = QHBoxLayout(self._nav_settings_btn)
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
|
||||
# No vertical padding of its own: ``_rebuild_nav`` pins this button to the
|
||||
# nav rows' OWN height, so the 6px a row pads with is already inside
|
||||
# that number. Adding it again here made the row taller than the button
|
||||
# (28 wanted, 20 given), which both clipped the icon and pushed the text
|
||||
# 8px below an even pitch with Dashboard / Giám sát.
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
|
||||
srow.setSpacing(_NAV_ROW_GAP)
|
||||
self._nav_settings_icon = QLabel()
|
||||
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
|
||||
@@ -63,6 +68,15 @@ class TopBarMixin:
|
||||
srow.addWidget(self._nav_settings_icon)
|
||||
srow.addWidget(self._nav_settings_text)
|
||||
srow.addStretch(1)
|
||||
# The first _rebuild_nav() ran before this button existed (it is what
|
||||
# fills the list this row belongs under), so take the height here too.
|
||||
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
|
||||
# Khe TRÊN hàng Cài đặt, xin thẳng từ layout — thanh rail đặt
|
||||
# ``setSpacing(0)`` nên không có khoảng nào sẵn, và margin trong QSS thì
|
||||
# không mua được pixel nào (xem ``_NAV_SETTINGS_GAP``). Cài đặt là việc
|
||||
# khác với nhóm Dashboard/Giám sát ngay trên nó; dán sát vào thì hai thứ
|
||||
# đọc thành một khối.
|
||||
nvl.addSpacing(_NAV_SETTINGS_GAP)
|
||||
nvl.addWidget(self._nav_settings_btn)
|
||||
self._account_row = self._build_account_row()
|
||||
|
||||
@@ -191,10 +205,42 @@ class TopBarMixin:
|
||||
# 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))
|
||||
)
|
||||
# Khong bao "dang dung <provider>" o thanh trang thai: chinh bo chon
|
||||
# provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong
|
||||
# bao chi nhac lai mot thu nguoi dung vua tu tay lam.
|
||||
def _lang_busy_overlay(self):
|
||||
"""The window's busy cover, built on first use.
|
||||
|
||||
Built lazily so a window that never changes language never gets one —
|
||||
and so ``_open_settings`` can be checked for "no switch, no flash".
|
||||
"""
|
||||
overlay = getattr(self, "_lang_busy", None)
|
||||
if overlay is None:
|
||||
from .busy_overlay import BusyOverlay
|
||||
overlay = BusyOverlay(self)
|
||||
self._lang_busy = overlay
|
||||
return overlay
|
||||
|
||||
def _switch_language(self, lang: str) -> None:
|
||||
"""Apply a new UI language behind a busy cover.
|
||||
|
||||
``set_language`` runs every registered widget's re-translation on the
|
||||
GUI thread, which on a large skill library takes long enough to look
|
||||
like a hang. Nothing can raise a cover once that has started (no event
|
||||
loop is left running), so it goes up FIRST — see ``busy_overlay.py``.
|
||||
|
||||
The message is read before the switch on purpose: mid-switch the only
|
||||
language the user can still read is the one being left behind.
|
||||
"""
|
||||
message = tr("app.lang.switching")
|
||||
self.language_combo.setEnabled(False)
|
||||
try:
|
||||
self._lang_busy_overlay().run_blocking(message, lambda: set_language(lang))
|
||||
finally:
|
||||
# In a ``finally`` so a listener that raises cannot leave the
|
||||
# switcher locked for the rest of the session.
|
||||
self.language_combo.setEnabled(True)
|
||||
|
||||
def _on_language_changed(self, _idx: int) -> None:
|
||||
"""Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại
|
||||
toàn bộ chữ vô ích.
|
||||
@@ -204,7 +250,7 @@ class TopBarMixin:
|
||||
return
|
||||
self.ctx.config.language = lang
|
||||
self.ctx.save()
|
||||
set_language(lang) # notifies every registered persistent widget
|
||||
self._switch_language(lang) # notifies every registered persistent widget
|
||||
def _open_settings(self) -> None:
|
||||
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
|
||||
dlg = SettingsDialog(self.ctx, self)
|
||||
@@ -215,7 +261,10 @@ class TopBarMixin:
|
||||
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
|
||||
# Guarded, not left to set_language's own no-op check: the cover
|
||||
# around the switch would otherwise flash on every Save.
|
||||
if self.ctx.config.language != get_language():
|
||||
self._switch_language(self.ctx.config.language)
|
||||
# reflect provider/theme/language changes
|
||||
i = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
if i >= 0:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Màn Workspace — phần đã bóc khỏi ``ui/workspace_tab.py``."""
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Danh sách project và việc sửa một project — bóc khỏi ``ui/workspace_tab.py``.
|
||||
|
||||
Bóc ra vì ``ui/workspace_tab.py`` đã chạm đúng trần bánh cóc của nó trong
|
||||
``scripts/check_loc.py::LEGACY_ALLOWANCE`` (566 dòng mã): file đó chỉ được co
|
||||
lại, không được phình thêm. Bốn tính năng dưới đây đều thêm mã vào cùng một chỗ,
|
||||
nên chúng đi cùng nhau sang đây thay vì bị nhét vào một file đã đầy.
|
||||
|
||||
Gộp bốn thứ vào một module không phải để lách trần — chúng là một khối:
|
||||
|
||||
* **Chế độ chỉ-xem.** Project đã có mở ra ở dạng chỉ đọc; phải bấm "Sửa project"
|
||||
mới gõ được. Trước đây form luôn mở, nên chỉ cần lỡ tay là sửa mất nội dung của
|
||||
một project đang dùng mà không có gì cảnh báo.
|
||||
* **Chặn trùng tên.** Hai project cùng tên thì danh sách bên trái không còn phân
|
||||
biệt được, mà tên lại là thứ duy nhất dòng đó hiển thị.
|
||||
* **Menu chuột phải.** Bấm phải vào một project trước đây không làm gì cả.
|
||||
* **Dựng lại nhãn số liệu khi đổi ngôn ngữ.** ``workspace.counts`` được format
|
||||
một lần lúc dựng dòng, nên đổi ngôn ngữ xong nó vẫn nằm ở ngôn ngữ cũ.
|
||||
|
||||
Ba việc đầu phải nằm chung: "Sửa" trong menu chuột phải bật đúng chế độ mà nút
|
||||
"Sửa project" bật, và việc chặn trùng tên chỉ có nghĩa khi biết form đang sửa
|
||||
project nào.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel, QLayout, QMenu, QMessageBox, QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import confirm
|
||||
|
||||
#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``.
|
||||
_DEFAULT_PROJECT_NAME = "Project"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Số liệu được giữ lại dưới dạng SỐ (``chats``/``tasks``) chứ không chỉ chuỗi
|
||||
đã format, để đổi ngôn ngữ còn dựng lại được câu mà không phải đọc lại đĩa.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, chats: int, tasks: int) -> None:
|
||||
"""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__()
|
||||
self.chats = chats
|
||||
self.tasks = tasks
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(6, 4, 6, 4)
|
||||
lay.setSpacing(0)
|
||||
self.title_label = QLabel(name)
|
||||
self.counts_label = QLabel()
|
||||
self.counts_label.setObjectName("hint")
|
||||
lay.addWidget(self.title_label)
|
||||
lay.addWidget(self.counts_label)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Dựng lại dòng số liệu theo ngôn ngữ đang chọn."""
|
||||
self.counts_label.setText(
|
||||
tr("workspace.counts", chats=self.chats, tasks=self.tasks))
|
||||
|
||||
|
||||
def _row_layout_of(widget: QWidget) -> QLayout | None:
|
||||
"""Layout đang trực tiếp chứa ``widget``.
|
||||
|
||||
Qt không cho hỏi ngược "ai đang giữ tôi", nên phải duyệt cây layout của
|
||||
widget cha. Dùng để chèn nút "Sửa project" ngay cạnh nút Lưu mà không phải
|
||||
thêm dòng nào vào ``_build_project_tab`` (xem docstring đầu file).
|
||||
"""
|
||||
parent = widget.parentWidget()
|
||||
if parent is None:
|
||||
return None
|
||||
stack = [parent.layout()]
|
||||
while stack:
|
||||
layout = stack.pop()
|
||||
if layout is None:
|
||||
continue
|
||||
for i in range(layout.count()):
|
||||
item = layout.itemAt(i)
|
||||
if item.widget() is widget:
|
||||
return layout
|
||||
if item.layout() is not None:
|
||||
stack.append(item.layout())
|
||||
return None
|
||||
|
||||
|
||||
class ProjectEditingMixin:
|
||||
"""Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``.
|
||||
|
||||
Cùng kiểu mixin với ``presentation/shell/`` (``NavRailMixin``,
|
||||
``RailProjectMixin``, …): lớp này đọc thẳng các thuộc tính mà
|
||||
``WorkspaceTab`` dựng (``project_list``, ``name_edit``, ``_save_btn``, …)
|
||||
nên nó không dùng lại được ở nơi khác — mục đích là chia file, không phải
|
||||
dựng một lớp trừu tượng.
|
||||
"""
|
||||
|
||||
# ---- lắp đặt ---------------------------------------------------------
|
||||
|
||||
def install_project_editing(self) -> None:
|
||||
"""Dựng nút "Sửa project", nối menu chuột phải, và khoá form lại.
|
||||
|
||||
Gọi một lần, sau khi ``_build_project_tab`` đã dựng xong các widget.
|
||||
"""
|
||||
self._project_editable = False
|
||||
|
||||
self._edit_btn = QPushButton()
|
||||
self._edit_btn.setObjectName("warning")
|
||||
self._edit_btn.clicked.connect(self.enter_project_edit_mode)
|
||||
|
||||
# Ba nut cua man quan ly project ve chung MOT hang, canh "Project moi" o
|
||||
# hang tieu de. Truoc do "Luu project" nam duoi cung khung ben phai, cach
|
||||
# "Project moi" gan het chieu cao man hinh, nen ba viec cung mot nhom lai
|
||||
# o ba cho khac nhau. Hang tieu de vat ngang ca man Workspace va da chi
|
||||
# hien tren sub-tab Project (xem _new_btn.setVisible(on_project)).
|
||||
title_row = _row_layout_of(self._new_btn)
|
||||
if title_row is not None:
|
||||
at = title_row.indexOf(self._new_btn) + 1
|
||||
title_row.insertWidget(at, self._edit_btn)
|
||||
title_row.insertWidget(at + 1, self._save_btn)
|
||||
else:
|
||||
save_row = _row_layout_of(self._save_btn)
|
||||
if save_row is not None:
|
||||
save_row.insertWidget(save_row.count() - 1, self._edit_btn)
|
||||
|
||||
# Hang tieu de vat ngang CA man Workspace, nen thu gi dat len do cung
|
||||
# hien o Cowork, Co4E, Thu muc va GraphRAG neu khong tu an di. Do la
|
||||
# dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong
|
||||
# WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay.
|
||||
self.tabs.currentChanged.connect(self._sync_project_buttons)
|
||||
|
||||
self.project_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.project_list.customContextMenuRequested.connect(self._show_project_menu)
|
||||
|
||||
self.set_project_editable(False)
|
||||
|
||||
# ---- chế độ chỉ-xem / sửa -------------------------------------------
|
||||
|
||||
def _editable_fields(self) -> list:
|
||||
"""Các ô mà chế độ sửa mở khoá. ``folder_lbl`` không có ở đây: nó vốn
|
||||
chỉ đọc, đường dẫn đổi qua nút Chọn thư mục."""
|
||||
return [self.name_edit, self.desc_edit, self.instr_edit]
|
||||
|
||||
def set_project_editable(self, on: bool) -> None:
|
||||
"""Bật/tắt chế độ sửa của form project.
|
||||
|
||||
Tắt: các ô thành chỉ đọc, nút Lưu và Chọn thư mục mờ đi, nút Sửa hiện ra.
|
||||
Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``).
|
||||
"""
|
||||
self._project_editable = on
|
||||
has_project = bool(getattr(self, "_current_id", ""))
|
||||
|
||||
for field in self._editable_fields():
|
||||
# setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy
|
||||
# được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem.
|
||||
field.setReadOnly(not on)
|
||||
self._browse_btn.setEnabled(on and has_project)
|
||||
self._save_btn.setEnabled(on and has_project)
|
||||
self._edit_btn.setEnabled(not on and has_project)
|
||||
self._sync_project_buttons()
|
||||
|
||||
# Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc
|
||||
# định khi chỉ xem. Màu lấy từ token trong theme/ — xem theme/__init__.py.
|
||||
self._save_btn.setObjectName("success" if on else "primary")
|
||||
self._repolish(self._save_btn)
|
||||
self._repolish(self._edit_btn)
|
||||
|
||||
def _sync_project_buttons(self, *_a) -> None:
|
||||
"""Ẩn "Sửa project" và "Lưu project" ngoài sub-tab Project.
|
||||
|
||||
Chúng nằm trên hàng tiêu đề dùng chung, nên không tự ẩn là chúng hiện
|
||||
cả ở Cowork — nơi không có biểu mẫu project nào để sửa hay lưu.
|
||||
"""
|
||||
on_project = self.tabs.currentIndex() == self._project_tab_idx
|
||||
has_project = bool(getattr(self, "_current_id", ""))
|
||||
self._edit_btn.setVisible(on_project and has_project)
|
||||
self._save_btn.setVisible(on_project and has_project)
|
||||
|
||||
@staticmethod
|
||||
def _repolish(widget: QWidget) -> None:
|
||||
"""Ép Qt đánh giá lại style sau khi đổi ``objectName``.
|
||||
|
||||
Không có bước này thì QSS đã khớp lúc dựng vẫn giữ nguyên — đổi
|
||||
objectName một mình không kích hoạt tính lại (bẫy P08).
|
||||
"""
|
||||
widget.style().unpolish(widget)
|
||||
widget.style().polish(widget)
|
||||
|
||||
def enter_project_edit_mode(self) -> None:
|
||||
"""Mở khoá form và đưa con trỏ vào ô Tên."""
|
||||
if not getattr(self, "_current_id", ""):
|
||||
return
|
||||
self.set_project_editable(True)
|
||||
self.name_edit.setFocus()
|
||||
|
||||
# ---- menu chuột phải -------------------------------------------------
|
||||
|
||||
def _show_project_menu(self, pos) -> None:
|
||||
"""Menu chuột phải trên một dòng project: Mở / Sửa / Xoá.
|
||||
|
||||
Bấm phải vào một dòng trước đây không làm gì cả — ba việc này đều đã
|
||||
có sẵn ở nơi khác trên màn hình, menu chỉ là đường vào thứ hai.
|
||||
"""
|
||||
item = self.project_list.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
# Bấm phải cũng chọn dòng đó, để menu thao tác lên đúng thứ vừa bấm
|
||||
# chứ không lên dòng đang được chọn từ trước.
|
||||
self.project_list.setCurrentItem(item)
|
||||
|
||||
menu = QMenu(self.project_list)
|
||||
open_act = menu.addAction(tr("workspace.menu_open"))
|
||||
edit_act = menu.addAction(tr("workspace.menu_edit"))
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(tr("workspace.menu_delete"))
|
||||
|
||||
chosen = menu.exec(self.project_list.viewport().mapToGlobal(pos))
|
||||
if chosen is open_act:
|
||||
self.open_selected_project()
|
||||
elif chosen is edit_act:
|
||||
self.enter_project_edit_mode()
|
||||
elif chosen is del_act:
|
||||
self._delete()
|
||||
|
||||
def open_selected_project(self) -> None:
|
||||
"""Mở project đang chọn: sang sub-tab Cowork của chính nó."""
|
||||
self._load_current()
|
||||
self._show_cowork_tab()
|
||||
|
||||
# ---- trùng tên -------------------------------------------------------
|
||||
|
||||
def _name_taken(self, name: str, *, ignore_id: str = "") -> bool:
|
||||
"""Đã có project khác mang tên này chưa.
|
||||
|
||||
So không phân biệt hoa thường và bỏ khoảng trắng hai đầu: với người
|
||||
dùng thì "Báo cáo" và "báo cáo " là cùng một cái tên, mà tên lại là thứ
|
||||
duy nhất dòng trong danh sách hiển thị.
|
||||
"""
|
||||
from ...core.projects import list_projects
|
||||
|
||||
wanted = (name or "").strip().casefold()
|
||||
if not wanted:
|
||||
return False
|
||||
return any(p.name.strip().casefold() == wanted and p.project_id != ignore_id
|
||||
for p in list_projects())
|
||||
|
||||
def _warn_duplicate_name(self, name: str) -> None:
|
||||
"""Báo cho người dùng biết tên đã có, và nói rõ tên nào."""
|
||||
QMessageBox.warning(self, tr("workspace.name_taken_title"),
|
||||
tr("workspace.name_taken_body", name=name.strip()))
|
||||
self.name_edit.setFocus()
|
||||
self.name_edit.selectAll()
|
||||
|
||||
# ---- CRUD (chuyển từ ui/workspace_tab.py sang) -----------------------
|
||||
|
||||
def _create(self) -> None:
|
||||
"""Tạo project mới với tên mặc định rồi chọn nó.
|
||||
|
||||
Tên mặc định phải là tên CHƯA có: người dùng bấm "Project mới" hai lần
|
||||
liên tiếp thì lần thứ hai sẽ trùng lần thứ nhất.
|
||||
"""
|
||||
from ...core.projects import new_project
|
||||
|
||||
# KHONG dung tr() cho ten mac dinh: ten project duoc GHI XUONG DIA. Tao
|
||||
# project luc dang o tieng Nhat thi ten no thanh "新規プロジェクト" vinh
|
||||
# vien, va doi ngon ngu ve tieng Viet khong sua duoc — do la du lieu, khong
|
||||
# phai chu giao dien. Nguoi dung nhin thay chu Nhat tren man hinh tieng
|
||||
# Viet va tuong la loi hien thi. Mot ten trung tinh khong bao gio noi doi.
|
||||
base = _DEFAULT_PROJECT_NAME
|
||||
name, n = base, 2
|
||||
while self._name_taken(name):
|
||||
name, n = f"{base} ({n})", n + 1
|
||||
|
||||
project = new_project(name)
|
||||
self._current_id = project.project_id
|
||||
self.refresh()
|
||||
self.projects_changed.emit()
|
||||
# Project vừa tạo thì mở sẵn chế độ sửa: chưa đặt tên thì chưa dùng được.
|
||||
self.set_project_editable(True)
|
||||
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 not confirm(self, tr("workspace.delete"),
|
||||
tr("workspace.delete_confirm", name=project.name)):
|
||||
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
|
||||
new_name = self.name_edit.text().strip()
|
||||
# Chặn TRƯỚC khi ghi: ghi xong rồi mới báo là đã hỏng dữ liệu trên đĩa.
|
||||
if new_name and self._name_taken(new_name, ignore_id=pid):
|
||||
self._warn_duplicate_name(new_name)
|
||||
return
|
||||
project.name = new_name 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))
|
||||
# Lưu xong thì khoá lại: chế độ sửa là một lần sửa, không phải trạng
|
||||
# thái dính cho tới khi người dùng nhớ ra phải tắt.
|
||||
self.set_project_editable(False)
|
||||
|
||||
# ---- i18n ------------------------------------------------------------
|
||||
|
||||
def retranslate_project_rows(self) -> None:
|
||||
"""Dựng lại nhãn số liệu của mọi dòng project theo ngôn ngữ mới.
|
||||
|
||||
KHÔNG gọi ``refresh()`` ở đây: ``refresh()`` kết thúc bằng
|
||||
``_load_current()``, nạp lại form từ đĩa — đổi ngôn ngữ giữa chừng sẽ
|
||||
xoá mất nội dung người dùng đang gõ dở.
|
||||
"""
|
||||
for i in range(self.project_list.count()):
|
||||
row = self.project_list.itemWidget(self.project_list.item(i))
|
||||
if isinstance(row, ProjectRow):
|
||||
row.retranslate()
|
||||
if hasattr(self, "_edit_btn"):
|
||||
self._edit_btn.setText(tr("workspace.edit_project"))
|
||||
Reference in New Issue
Block a user