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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user