merge: lấy phần N3 của Lâm (6 widget UI Co4E) về nhánh chung
Không xung đột — Lâm động vào ui/co4e_tab.py và presentation/co4e/, tôi động vào ui/settings_dialog.py và presentation/settings/. Đúng như quy tắc phân chia sở hữu đặt ra từ đầu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"""Panel khu vực AGENTS của sidebar Co4E — tách khỏi ``ui/co4e_tab.py``.
|
||||
|
||||
Vấn đề đang có: giống ``SkillsListPanel`` (xem
|
||||
``presentation/co4e/skills_list_panel.py``), đoạn dựng widget khu vực AGENTS
|
||||
nằm nguyên trong thân hàm dựng cả cột sidebar của ``ui/co4e_tab.py`` (nguyên
|
||||
bản ở dòng 549-568): nút "+ Mới", danh sách kéo-thả và 2 nút icon Sửa/Xoá.
|
||||
Đoạn này không đọc/ghi bất kỳ trạng thái nào của ``Co4ETab`` khi DỰNG (chỉ khi
|
||||
người dùng bấm nút mới cần tới ``_new_agent``/``_edit_agent``/``_delete_agent``
|
||||
của ``Co4ETab``), nên tách được thành một ``QWidget`` con độc lập.
|
||||
|
||||
Cách làm: dời nguyên phần dựng widget sang đây, KHÔNG đổi tên thuộc tính so
|
||||
với bản gốc ngoại trừ đổi TÊN THUỘC TÍNH cho khớp quy ước panel công khai
|
||||
(``ag_new_btn`` → ``new_btn``, ``agent_list`` → ``list_widget``,
|
||||
``ag_edit_btn`` → ``edit_btn``, ``ag_del_btn`` → ``del_btn``); giá trị/thứ tự
|
||||
dựng thì giữ y hệt. Panel KHÔNG tự nối ``.clicked`` của bất kỳ nút nào — theo
|
||||
đúng nguyên tắc "một việc rẽ ra một lần" đã dùng cho ``SkillsListPanel``: việc
|
||||
dựng widget (ở đây) tách khỏi việc nối hành vi (vẫn ở ``Co4ETab``, nơi biết
|
||||
``_new_agent``/``_edit_agent``/``_delete_agent`` là gì). Gộp hai việc đó vào
|
||||
panel sẽ buộc panel phải biết về ``Co4ETab``, xoá mất lý do tách nó ra.
|
||||
|
||||
``new_btn`` được tạo nhưng KHÔNG add vào layout của panel này — giống hệt
|
||||
``wf_new_btn``/``sk_manage_btn`` ở bản gốc: nút này được ``Co4ETab`` truyền
|
||||
riêng làm "action" của tiêu đề section (tham số ``action`` của ``_section``),
|
||||
không nằm trong phần thân (list + nút icon) mà panel này đóng vai trò thay
|
||||
thế. Panel do đó chỉ tự dựng layout cho list_widget + hàng nút edit/del.
|
||||
|
||||
Cập nhật 25/08: ``_PaletteList`` đã dời khỏi ``ui/co4e_tab.py`` sang
|
||||
``presentation/co4e/palette_list.py`` (module dùng chung, không phụ thuộc
|
||||
``ui/``) — import thẳng ở top-level như bên dưới, không còn cần deferred-import
|
||||
né vòng lặp nữa (xem docstring đầu ``presentation/co4e/palette_list.py`` để
|
||||
biết lý do dời).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon
|
||||
from .palette_list import _PaletteList
|
||||
|
||||
|
||||
class AgentListPanel(QWidget):
|
||||
"""Widget khu vực AGENTS của sidebar Co4E: nút mới + danh sách + sửa/xoá.
|
||||
|
||||
Vai trò: một widget con thuần ở tầng presentation, chỉ dựng cấu trúc UI
|
||||
(đúng những gì ``ui/co4e_tab.py`` dòng 549-568 làm trước đây), không biết
|
||||
gì về ``Co4ETab``/``_new_agent``/``_edit_agent``/``_delete_agent``. Bên
|
||||
gọi (hiện là ``Co4ETab``) tự đọc ``.new_btn``/``.list_widget``/
|
||||
``.edit_btn``/``.del_btn`` để nối signal và nạp dữ liệu — panel không tự
|
||||
làm hộ, để giữ đúng ranh giới "một nơi một việc" đã dùng cho
|
||||
``SkillsListPanel``.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.new_btn = QPushButton(tr("co4e.new"))
|
||||
self.new_btn.setIcon(icon("plus"))
|
||||
self.new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
||||
self.new_btn.setObjectName("co4eSectionAction")
|
||||
self.new_btn.setFlat(True)
|
||||
self.new_btn.setCursor(Qt.PointingHandCursor)
|
||||
# KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao
|
||||
# xu ly - panel chi dung widget, khong biet _new_agent la gi.
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
self.list_widget = _PaletteList()
|
||||
layout.addWidget(self.list_widget, 1)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
btns.setSpacing(4)
|
||||
# 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"))
|
||||
self.edit_btn.setFixedWidth(34)
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setToolTip(tr("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)
|
||||
btns.addWidget(self.del_btn)
|
||||
btns.addStretch(1)
|
||||
layout.addLayout(btns)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Hình học thuần cho canvas Co4E — tách khỏi ``ui/co4e_canvas.py``.
|
||||
|
||||
Vấn đề đang có: ``ui/co4e_canvas.py`` dài hơn 2000 dòng, gộp chung widget Qt
|
||||
(``QGraphicsItem``, vẽ, sự kiện chuột) với các hàm hình học thuần (khoảng cách,
|
||||
nội suy điểm, dựng đường bo góc, né vật cản, cắt chuỗi). Các hàm hình học này
|
||||
không cần ``QApplication``, không vẽ, không đọc kích thước widget — chúng chỉ
|
||||
dùng ``QPointF``/``QRectF``/``QPainterPath`` như kiểu giá trị thuần. Gộp chung
|
||||
vào một file khiến file đó khó đọc và khó kiểm tra theo giới hạn CASAN (≤400
|
||||
dòng mỗi file production).
|
||||
|
||||
Cách làm: dời nguyên 8 hàm này sang đây, không đổi tên/tham số/giá trị mặc
|
||||
định/hành vi — kể cả các "quirk" đã bị characterization test đóng đinh (xem
|
||||
``tests/characterization/test_co4e_canvas_geometry.py``), ví dụ ``_route`` có
|
||||
thể "bỏ cuộc" và trả về elbow va chạm nếu bị vật cản bao kín hoàn toàn, hoặc
|
||||
``_elide(text, 0)`` trả về ``"…"`` chứ không phải chuỗi rỗng do cách slicing
|
||||
``text[: n - 1]``. Đừng "sửa" các quirk này ở đây — chúng đã có test khoá lại,
|
||||
sửa sai chỗ này sẽ làm vỡ hợp đồng mà nơi khác đang phụ thuộc.
|
||||
|
||||
``ui/co4e_canvas.py`` import lại các tên này (không alias) để giữ nguyên đường
|
||||
import public mà các test/character khác đang dùng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF
|
||||
from PySide6.QtGui import QPainterPath
|
||||
|
||||
_CORNER_R = 12 # edge elbow corner radius
|
||||
|
||||
|
||||
def _dist(a: QPointF, b: QPointF) -> float:
|
||||
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
|
||||
|
||||
|
||||
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
|
||||
dist = _dist(a, b)
|
||||
if dist < 1e-6:
|
||||
return QPointF(a)
|
||||
t = d / dist
|
||||
return QPointF(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t)
|
||||
|
||||
|
||||
def _rounded_path(points, r: float = _CORNER_R) -> QPainterPath:
|
||||
"""Build a path through axis-aligned ``points`` with rounded corners at each
|
||||
bend ("vuông bo cong ở góc")."""
|
||||
if not points:
|
||||
return QPainterPath()
|
||||
path = QPainterPath(points[0])
|
||||
if len(points) == 1:
|
||||
return path
|
||||
for i in range(1, len(points) - 1):
|
||||
prev, cur, nxt = points[i - 1], points[i], points[i + 1]
|
||||
rr = min(r, _dist(prev, cur) / 2.0, _dist(cur, nxt) / 2.0)
|
||||
path.lineTo(_towards(cur, prev, rr))
|
||||
path.quadTo(cur, _towards(cur, nxt, rr))
|
||||
path.lineTo(points[-1])
|
||||
return path
|
||||
|
||||
|
||||
def _seg_hits_rect(p1: QPointF, p2: QPointF, rect: QRectF) -> bool:
|
||||
"""Axis-aligned segment vs rectangle overlap (all routed segments are H or V)."""
|
||||
x1, y1, x2, y2 = p1.x(), p1.y(), p2.x(), p2.y()
|
||||
if abs(y1 - y2) < 0.5: # horizontal
|
||||
if rect.top() <= y1 <= rect.bottom():
|
||||
lo, hi = sorted((x1, x2))
|
||||
return not (hi < rect.left() or lo > rect.right())
|
||||
return False
|
||||
if abs(x1 - x2) < 0.5: # vertical
|
||||
if rect.left() <= x1 <= rect.right():
|
||||
lo, hi = sorted((y1, y2))
|
||||
return not (hi < rect.top() or lo > rect.bottom())
|
||||
return False
|
||||
box = QRectF(QPointF(min(x1, x2), min(y1, y2)), QPointF(max(x1, x2), max(y1, y2)))
|
||||
return rect.intersects(box)
|
||||
|
||||
|
||||
def _hits(points, obstacles) -> bool:
|
||||
for i in range(len(points) - 1):
|
||||
for r in obstacles:
|
||||
if _seg_hits_rect(points[i], points[i + 1], r):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _route(src: QPointF, dst: QPointF, obstacles=None):
|
||||
"""Waypoints for a LEFT→RIGHT orthogonal edge from ``src`` (a node's right
|
||||
output) to ``dst`` (the next node's left input) that AVOIDS the other node
|
||||
rectangles: try the straight elbow, then a clear vertical band, then a
|
||||
top/bottom detour — so a connector never overlaps or hides behind a step."""
|
||||
obstacles = list(obstacles or [])
|
||||
if abs(src.y() - dst.y()) < 1.5:
|
||||
cand = [src, dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
mid_x = (src.x() + dst.x()) / 2.0
|
||||
base = [src, QPointF(mid_x, src.y()), QPointF(mid_x, dst.y()), dst]
|
||||
if not _hits(base, obstacles):
|
||||
return base
|
||||
# 1) slide the vertical run to a clear band between the two columns
|
||||
lo, hi = min(src.x(), dst.x()) + 6, max(src.x(), dst.x()) - 6
|
||||
if hi > lo:
|
||||
for frac in (0.5, 0.35, 0.65, 0.2, 0.8):
|
||||
x = lo + (hi - lo) * frac
|
||||
cand = [src, QPointF(x, src.y()), QPointF(x, dst.y()), dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
# 2) detour above/below every obstacle, then back in
|
||||
margin = 44.0
|
||||
ys = [src.y(), dst.y()] + [r.top() for r in obstacles] + [r.bottom() for r in obstacles]
|
||||
out_x, in_x = src.x() + 34, dst.x() - 34 # short stubs out of the side ports
|
||||
for side_y in (min(ys) - margin, max(ys) + margin):
|
||||
cand = [src, QPointF(out_x, src.y()), QPointF(out_x, side_y),
|
||||
QPointF(in_x, side_y), QPointF(in_x, dst.y()), dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
return base
|
||||
|
||||
|
||||
def _ortho_path(src: QPointF, dst: QPointF, r: float = _CORNER_R) -> QPainterPath:
|
||||
"""Rounded orthogonal elbow (no obstacle avoidance) — used for the transient
|
||||
drag-to-connect line and by callers that pass no obstacles."""
|
||||
return _rounded_path(_route(src, dst), r)
|
||||
|
||||
|
||||
def _elide(text: str, n: int) -> str:
|
||||
text = (text or "").replace("\n", " ")
|
||||
return text if len(text) <= n else text[: n - 1] + "…"
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Mixin xử lý tương tác (zoom/pan/relayout/drop) của canvas Co4E — dời khỏi
|
||||
``ui/co4e_canvas.py``.
|
||||
|
||||
Vấn đề đang có: gộp toàn bộ ``Co4ECanvas`` (dòng 289-701 của file cũ) vào một
|
||||
file duy nhất vẫn dư 413 dòng — vượt trần 400 dòng/file production của CASAN
|
||||
Check 2 dù đã tách ``_NodeItem``/``_EdgeItem`` ra ``canvas_items.py`` rồi. Khối
|
||||
còn lại chia làm hai nhóm trách nhiệm tự nhiên: (1) mutation đồ thị (add/delete
|
||||
node/edge, port-drag) và (2) tương tác view thuần tuý (overlay góc, zoom,
|
||||
pan-chuột-giữa, fit/relayout, phím tắt, kéo-thả từ sidebar). Nhóm (2) được cắt
|
||||
ra đây thành MIXIN THUẦN — không có ``__init__`` riêng, không tự gọi
|
||||
``super().__init__()`` — vì toàn bộ state nó dùng (``self._overlay``,
|
||||
``self._zoom``, ``self._panning``, ``self._pan_start``, ``self._nodes``,
|
||||
``self._edges``, ``self._scene``, ``self._connect_from``, ``self._temp_edge``,
|
||||
hằng số lớp ``self._ZOOM_MIN``/``self._ZOOM_MAX``) do ``Co4ECanvas.__init__``
|
||||
định nghĩa; mixin chỉ mượn ``self`` khi đã được trộn vào lớp đó.
|
||||
|
||||
Cách làm: cắt dán NGUYÊN VĂN các khối dòng 318-345, 484-519, 522-548, 550-610,
|
||||
652-701 của ``ui/co4e_canvas.py`` — không đổi tên/tham số/thứ tự/logic, kể cả
|
||||
inline import ``from collections import defaultdict`` bên trong ``relayout``
|
||||
hay hai inline import ``from ..core.co4e import ...`` bên trong ``dropEvent``
|
||||
(chỉ đổi SỐ DẤU CHẤM cho đúng cấp thư mục mới — xem chú thích tại chỗ).
|
||||
|
||||
Thứ tự kế thừa bắt buộc ở nơi dùng (``co4e_canvas_widget.py``):
|
||||
``class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView)`` — mixin đứng
|
||||
TRƯỚC ``QGraphicsView`` trong danh sách base để MRO ưu tiên các override ở
|
||||
đây (``mousePressEvent``/``mouseMoveEvent``/``mouseReleaseEvent``/
|
||||
``wheelEvent``/``resizeEvent``/``keyPressEvent``/``dragEnterEvent``/
|
||||
``dragMoveEvent``/``dropEvent``) thay vì rơi vào bản gốc của
|
||||
``QGraphicsView``. Mỗi ``super().xxxEvent(e)`` gọi trong file này dựa vào đúng
|
||||
thứ tự MRO đó để rơi xuống ``QGraphicsView.xxxEvent`` khi mixin không tự xử lý
|
||||
— không phải gọi đệ quy lại chính nó.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, Qt
|
||||
|
||||
from ...core.co4e import Edge, Node, compute_waves, new_edge_id, new_node_id
|
||||
from .canvas_items import CO4E_MIME, _NODE_H, _NODE_W, _NodeItem
|
||||
|
||||
|
||||
class _CanvasInteractionMixin:
|
||||
"""Phần tương tác view của ``Co4ECanvas``: overlay góc, zoom/pan, fit/
|
||||
relayout, phím tắt, kéo-thả từ sidebar. Xem docstring đầu module về lý do
|
||||
tách và ràng buộc thứ tự kế thừa MRO khi trộn vào ``Co4ECanvas``."""
|
||||
|
||||
# ---- bottom-left overlay (zoom / fit) --------------------------------
|
||||
def add_overlay(self, widget) -> None:
|
||||
self._overlay = widget
|
||||
widget.setParent(self.viewport())
|
||||
widget.show()
|
||||
widget.raise_()
|
||||
self._place_overlay()
|
||||
|
||||
def _place_overlay(self) -> None:
|
||||
if self._overlay is not None:
|
||||
self._overlay.adjustSize()
|
||||
vp = self.viewport()
|
||||
self._overlay.move(12, vp.height() - self._overlay.height() - 12)
|
||||
self._overlay.raise_()
|
||||
|
||||
def resizeEvent(self, e): # noqa: N802
|
||||
super().resizeEvent(e)
|
||||
self._place_overlay()
|
||||
|
||||
def scrollContentsBy(self, dx, dy): # noqa: N802
|
||||
# QGraphicsView scrolls the viewport's child widgets along with the
|
||||
# scene, so panning/scrolling would drag the zoom overlay off-corner.
|
||||
# Re-pin it after every scroll so +/−/fit stay fixed in place.
|
||||
super().scrollContentsBy(dx, dy)
|
||||
self._place_overlay()
|
||||
|
||||
def showEvent(self, e): # noqa: N802
|
||||
super().showEvent(e)
|
||||
self._place_overlay() # viewport size is final once shown
|
||||
|
||||
# ---- zoom / fit -------------------------------------------------------
|
||||
def _zoom_by(self, factor: float) -> None:
|
||||
# Derive the CURRENT scale from the live transform (never a separate
|
||||
# accumulator that can drift out of sync with fit_view/relayout/reset —
|
||||
# that drift is what made the +/− buttons and Ctrl+wheel randomly stop
|
||||
# working). Clamp the TARGET to the range and apply the exact factor to
|
||||
# reach it, so zooming still works right up to the limits.
|
||||
cur = self.transform().m11() or 1.0
|
||||
target = max(self._ZOOM_MIN, min(self._ZOOM_MAX, cur * factor))
|
||||
if abs(target - cur) < 1e-6:
|
||||
return
|
||||
self.scale(target / cur, target / cur)
|
||||
self._zoom = target
|
||||
|
||||
def zoom_in(self) -> None:
|
||||
self._zoom_by(1.15)
|
||||
|
||||
def zoom_out(self) -> None:
|
||||
self._zoom_by(1 / 1.15)
|
||||
|
||||
def reset_zoom(self) -> None:
|
||||
self.resetTransform()
|
||||
self._zoom = 1.0
|
||||
|
||||
def wheelEvent(self, e):
|
||||
# Ctrl+wheel = zoom (anchored under the cursor); Shift+wheel = pan
|
||||
# horizontally; plain wheel scrolls vertically.
|
||||
if e.modifiers() & Qt.ControlModifier:
|
||||
self._zoom_by(1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
||||
e.accept()
|
||||
return
|
||||
if e.modifiers() & Qt.ShiftModifier:
|
||||
bar = self.horizontalScrollBar()
|
||||
bar.setValue(bar.value() - e.angleDelta().y())
|
||||
e.accept()
|
||||
return
|
||||
super().wheelEvent(e)
|
||||
|
||||
# ---- middle-mouse drag-to-pan ----------------------------------------
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == Qt.MiddleButton:
|
||||
self._panning = True
|
||||
self._pan_start = e.position().toPoint()
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self._panning and self._pan_start is not None:
|
||||
pos = e.position().toPoint()
|
||||
delta = pos - self._pan_start
|
||||
self._pan_start = pos
|
||||
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x())
|
||||
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y())
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if e.button() == Qt.MiddleButton and self._panning:
|
||||
self._panning = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def fit_view(self) -> None:
|
||||
"""Auto-fit: zoom/pan so every node is visible with a small margin."""
|
||||
rect = self._scene.itemsBoundingRect()
|
||||
if rect.isNull():
|
||||
return
|
||||
self.setSceneRect(rect.adjusted(-60, -60, 60, 60))
|
||||
self.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||||
# keep the zoom accumulator in sync with the transform fitInView applied
|
||||
self._zoom = self.transform().m11() or 1.0
|
||||
|
||||
def relayout(self, hgap: float = 110.0, vgap: float = 40.0) -> None:
|
||||
"""Arrange nodes LEFT→RIGHT by dependency depth: each topological wave is
|
||||
a column (x = wave), siblings stacked vertically within it. Used to turn
|
||||
an old top-down graph into the horizontal flow layout."""
|
||||
nodes = [it.node for it in self._nodes.values()]
|
||||
edges = [it.edge for it in self._edges]
|
||||
if not nodes:
|
||||
return
|
||||
waves = compute_waves(nodes, edges)
|
||||
from collections import defaultdict
|
||||
cols: Dict[int, list] = defaultdict(list)
|
||||
for n in nodes:
|
||||
cols[waves.get(n.id, 0)].append(n)
|
||||
for w in sorted(cols):
|
||||
for row, n in enumerate(sorted(cols[w], key=lambda nn: (nn.y, nn.x))):
|
||||
item = self._nodes.get(n.id)
|
||||
if item is not None:
|
||||
item.setPos(w * (_NODE_W + hgap), row * (_NODE_H + vgap))
|
||||
self._reposition_edges()
|
||||
|
||||
def relayout_if_vertical(self) -> None:
|
||||
"""Convert a graph that's stacked vertically (the old top-down layout, or
|
||||
overlapping nodes) into the horizontal left→right layout — but leave a
|
||||
graph the user already arranged horizontally untouched."""
|
||||
nodes = [it.node for it in self._nodes.values()]
|
||||
if len(nodes) < 2:
|
||||
return
|
||||
xs = [n.x for n in nodes]
|
||||
if max(xs) - min(xs) < _NODE_W: # all in one column → it's vertical
|
||||
self.relayout()
|
||||
|
||||
def add_workflow(self, nodes, edges, at: Optional[QPointF] = None) -> None:
|
||||
"""Drop/merge a whole flow's nodes+edges onto the canvas with fresh ids
|
||||
(so the same template can be dropped several times). Offsets it near
|
||||
``at`` when given, else tiles it beside whatever is already there."""
|
||||
remap: Dict[str, str] = {}
|
||||
# offset so a dropped template doesn't land exactly on existing nodes
|
||||
ox = (at.x() - nodes[0].x) if (at and nodes) else (60 if self._nodes else 0)
|
||||
oy = (at.y() - nodes[0].y) if (at and nodes) else (60 if self._nodes else 0)
|
||||
for n in nodes:
|
||||
new = Node(id=new_node_id(), x=n.x + ox, y=n.y + oy, data=copy.deepcopy(n.data))
|
||||
remap[n.id] = new.id
|
||||
item = _NodeItem(new, self)
|
||||
self._nodes[new.id] = item
|
||||
self._scene.addItem(item)
|
||||
for e in edges:
|
||||
s, t = remap.get(e.source), remap.get(e.target)
|
||||
if s and t:
|
||||
self._add_edge_item(Edge(id=new_edge_id(s, t), source=s, target=t))
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
# ---- key / drop -------------------------------------------------------
|
||||
def keyPressEvent(self, e):
|
||||
if e.key() in (Qt.Key_Delete, Qt.Key_Backspace):
|
||||
self.delete_selected()
|
||||
return
|
||||
if e.key() == Qt.Key_Escape:
|
||||
self._connect_from = None
|
||||
if self._temp_edge is not None:
|
||||
self._scene.removeItem(self._temp_edge)
|
||||
self._temp_edge = None
|
||||
self._port_src = None
|
||||
return
|
||||
if e.key() in (Qt.Key_Plus, Qt.Key_Equal) and (e.modifiers() & Qt.ControlModifier):
|
||||
self.zoom_in(); return
|
||||
if e.key() == Qt.Key_Minus and (e.modifiers() & Qt.ControlModifier):
|
||||
self.zoom_out(); return
|
||||
if e.key() == Qt.Key_0 and (e.modifiers() & Qt.ControlModifier):
|
||||
self.reset_zoom(); return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
def dragEnterEvent(self, e):
|
||||
if e.mimeData().hasFormat(CO4E_MIME):
|
||||
e.acceptProposedAction()
|
||||
else:
|
||||
super().dragEnterEvent(e)
|
||||
|
||||
def dragMoveEvent(self, e):
|
||||
if e.mimeData().hasFormat(CO4E_MIME):
|
||||
e.acceptProposedAction()
|
||||
else:
|
||||
super().dragMoveEvent(e)
|
||||
|
||||
def dropEvent(self, e):
|
||||
if not e.mimeData().hasFormat(CO4E_MIME):
|
||||
super().dropEvent(e)
|
||||
return
|
||||
try:
|
||||
payload = json.loads(bytes(e.mimeData().data(CO4E_MIME)).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return
|
||||
pos = self.mapToScene(e.position().toPoint())
|
||||
if isinstance(payload, dict) and payload.get("kind") == "workflow":
|
||||
# A whole flow dragged from the sidebar → merge its graph in.
|
||||
# 3 dấu chấm vì file này giờ nằm ở presentation/co4e/ (sâu hơn
|
||||
# ui/ gốc 1 cấp) — cùng module core.co4e như bản gốc, chỉ đổi số
|
||||
# cấp cho đúng vị trí mới, không đổi cái được import.
|
||||
from ...core.co4e import workflow_from_dict
|
||||
wf = workflow_from_dict(payload.get("workflow", {}))
|
||||
if wf.nodes:
|
||||
self.add_workflow(wf.nodes, wf.edges, at=pos)
|
||||
else:
|
||||
from ...core.co4e import step_from_dict
|
||||
self.add_palette_step(step_from_dict(payload), pos)
|
||||
e.acceptProposedAction()
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Các item vẽ trực tiếp trên canvas Co4E — dời khỏi ``ui/co4e_canvas.py``.
|
||||
|
||||
Vấn đề đang có: gộp riêng ``Co4ECanvas`` (dòng 289-701 của file cũ) vào một
|
||||
file mới đã đủ 413 dòng, vượt trần 400 dòng/file production của CASAN Check 2
|
||||
— dù không đổi gì bên trong. ``_NodeItem``/``_EdgeItem`` (và hằng số/hàm phụ
|
||||
trợ chúng dùng để vẽ) là phần độc lập nhất về mặt trách nhiệm: chỉ vẽ và xử lý
|
||||
sự kiện chuột NGAY TRÊN item đó, gọi ngược vào canvas cha qua tham số
|
||||
``canvas`` được truyền ở constructor — nên tách được sang module riêng mà
|
||||
không cần đổi bất kỳ hành vi nào.
|
||||
|
||||
Cách làm: cắt dán NGUYÊN VĂN các khối dòng 43-56 (hằng số + ``_status_color``)
|
||||
và 59-286 (``_NodeItem``, ``_EdgeItem``) từ ``ui/co4e_canvas.py`` sang đây,
|
||||
không đổi tên/tham số/thứ tự/giá trị mặc định — kể cả các quirk đã bị
|
||||
characterization test (``tests/characterization/test_co4e_canvas_widget.py``,
|
||||
``test_co4e_canvas_geometry.py``) đóng đinh gián tiếp qua ``_rounded_path``/
|
||||
``_route``/``_elide`` mà ``_EdgeItem.update_path``/``_NodeItem.paint`` gọi.
|
||||
|
||||
Tham số ``canvas: "Co4ECanvas"`` trong ``__init__`` của cả hai lớp dùng string
|
||||
forward-reference vì ``Co4ECanvas`` giờ nằm ở module
|
||||
``co4e_canvas_widget.py`` khác — import trực tiếp sẽ tạo vòng lặp (canvas
|
||||
widget import ngược lại các item này). Đây thuần là type hint, không cần
|
||||
import runtime.
|
||||
|
||||
``ui/co4e_canvas.py`` import lại các tên public (``CO4E_MIME`` qua
|
||||
``co4e_canvas_widget.py``) để giữ nguyên đường import mà test/characterization
|
||||
khác đang dùng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
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 ...theme import current_palette
|
||||
from .canvas_geometry import _elide, _rounded_path
|
||||
|
||||
|
||||
def _status_color(status: str) -> str:
|
||||
"""Accent colour for a step's run status. Resolved per paint so the canvas
|
||||
follows a live theme switch."""
|
||||
p = current_palette()
|
||||
return {
|
||||
"idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success,
|
||||
STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint,
|
||||
}.get(status, p.text_muted)
|
||||
|
||||
CO4E_MIME = "application/x-co4e-step"
|
||||
|
||||
_NODE_W, _NODE_H = 210, 96
|
||||
_PORT_R = 6 # output port radius (the drag-to-connect handle)
|
||||
_PORT_HIT = 15 # click tolerance around a port
|
||||
|
||||
|
||||
class _NodeItem(QGraphicsObject):
|
||||
"""One draggable step card. Emits signals via the parent canvas."""
|
||||
|
||||
def __init__(self, node: Node, canvas: "Co4ECanvas"):
|
||||
super().__init__()
|
||||
self.node = node
|
||||
self.canvas = canvas
|
||||
self.status = "idle"
|
||||
self._porting = False
|
||||
self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable
|
||||
| QGraphicsItem.ItemSendsGeometryChanges)
|
||||
self.setAcceptHoverEvents(True)
|
||||
self.setPos(node.x, node.y)
|
||||
self.setZValue(2)
|
||||
|
||||
def boundingRect(self) -> QRectF:
|
||||
# slack left/right so the input/output ports (now on the sides) paint cleanly
|
||||
return QRectF(-_PORT_R - 2, -3, _NODE_W + 2 * _PORT_R + 4, _NODE_H + 6)
|
||||
|
||||
def _card_rect(self) -> QRectF:
|
||||
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
|
||||
|
||||
def paint(self, p, _opt, _widget=None):
|
||||
tok = current_palette()
|
||||
step = self.node.data
|
||||
accent = QColor(_status_color(self.status))
|
||||
body = QColor(tok.surface_raised)
|
||||
border = QColor(tok.accent) if self.isSelected() else QColor(tok.border)
|
||||
p.setRenderHint(p.RenderHint.Antialiasing)
|
||||
rect = self._card_rect()
|
||||
path = QPainterPath()
|
||||
radius = float(tok.radius_lg)
|
||||
path.addRoundedRect(rect, radius, radius)
|
||||
p.fillPath(path, QBrush(body))
|
||||
p.setPen(QPen(border, 2 if self.isSelected() else 1))
|
||||
p.drawPath(path)
|
||||
# header stripe — a tint of the status colour, not the status colour
|
||||
# itself, so the card's own text stays the brightest thing on it.
|
||||
hdr = QRectF(rect.left(), rect.top(), rect.width(), 26)
|
||||
hpath = QPainterPath()
|
||||
hpath.addRoundedRect(hdr, radius, radius)
|
||||
stripe = QColor(accent)
|
||||
stripe.setAlpha(48)
|
||||
p.fillPath(hpath, QBrush(stripe))
|
||||
# label
|
||||
p.setPen(QColor(tok.text))
|
||||
f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f)
|
||||
p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft,
|
||||
_elide(step.label, 26))
|
||||
# role badge + status
|
||||
f.setBold(False); f.setPointSize(8); p.setFont(f)
|
||||
p.setPen(accent)
|
||||
p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role)
|
||||
# body: instructions preview OR sub-agent chips
|
||||
p.setPen(QColor(tok.text_muted))
|
||||
if step.is_parallel:
|
||||
preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)"
|
||||
else:
|
||||
preview = step.instructions or "(no instructions)"
|
||||
p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop,
|
||||
_elide(preview, 66))
|
||||
# footer: model + skills + status dot
|
||||
p.setPen(QColor(tok.text_faint))
|
||||
foot = []
|
||||
if step.model:
|
||||
foot.append(step.model)
|
||||
if step.skills:
|
||||
foot.append(f"skills:{len(step.skills)}")
|
||||
foot.append(self.status)
|
||||
p.drawText(QRectF(10, _NODE_H - 18, _NODE_W - 20, 14), Qt.AlignLeft,
|
||||
_elide(" · ".join(foot), 34))
|
||||
# ---- ports ---------------------------------------------------------
|
||||
# input port (top-center): hollow. output port (bottom-center): filled —
|
||||
# the drag handle you pull to wire an edge to another step.
|
||||
port_col = QColor(tok.accent)
|
||||
# input port (left-center): hollow. output port (right-center): filled —
|
||||
# the drag handle you pull to wire an edge to the next step (left→right).
|
||||
p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4))
|
||||
p.drawEllipse(QPointF(1, _NODE_H / 2), _PORT_R - 1, _PORT_R - 1)
|
||||
p.setBrush(QBrush(port_col)); p.setPen(QPen(port_col, 1.4))
|
||||
p.drawEllipse(QPointF(_NODE_W - 1, _NODE_H / 2), _PORT_R, _PORT_R)
|
||||
|
||||
def _in_out_port(self, pos: QPointF) -> bool:
|
||||
d = pos - QPointF(_NODE_W, _NODE_H / 2)
|
||||
return (d.x() * d.x() + d.y() * d.y()) ** 0.5 <= _PORT_HIT
|
||||
|
||||
def itemChange(self, change, value):
|
||||
if change == QGraphicsItem.ItemPositionHasChanged:
|
||||
self.node.x = float(self.pos().x())
|
||||
self.node.y = float(self.pos().y())
|
||||
self.canvas._reposition_edges()
|
||||
self.canvas.graph_changed.emit()
|
||||
elif change == QGraphicsItem.ItemSelectedHasChanged:
|
||||
# a selected/edited node comes to the front (above the edges at z=3)
|
||||
self.setZValue(4 if value else 2)
|
||||
if value:
|
||||
self.canvas.node_selected.emit(self.node.id)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def hoverMoveEvent(self, e):
|
||||
# a hand cursor over the output port hints it's draggable-to-connect
|
||||
self.setCursor(Qt.PointingHandCursor if self._in_out_port(e.pos()) else Qt.ArrowCursor)
|
||||
super().hoverMoveEvent(e)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if self.canvas._connect_from is not None:
|
||||
self.canvas._finish_connect(self.node.id)
|
||||
e.accept()
|
||||
return
|
||||
if e.button() == Qt.LeftButton and self._in_out_port(e.pos()):
|
||||
# start a manual drag-to-connect from this node's output port
|
||||
self._porting = True
|
||||
self.canvas.begin_port_drag(self.node.id, self.mapToScene(QPointF(_NODE_W, _NODE_H / 2)))
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self._porting:
|
||||
self.canvas.update_port_drag(self.mapToScene(e.pos()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if self._porting:
|
||||
self._porting = False
|
||||
self.canvas.finish_port_drag(self.mapToScene(e.pos()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def mouseDoubleClickEvent(self, e):
|
||||
self.canvas.node_activated.emit(self.node.id)
|
||||
e.accept()
|
||||
|
||||
def contextMenuEvent(self, e):
|
||||
menu = QMenu()
|
||||
a_add = menu.addAction("+ Add next step")
|
||||
a_conn = menu.addAction("→ Connect from here")
|
||||
a_del = menu.addAction("🗑 Delete step")
|
||||
chosen = menu.exec(e.screenPos())
|
||||
if chosen is a_add:
|
||||
self.canvas.add_step_below(self.node.id)
|
||||
elif chosen is a_conn:
|
||||
self.canvas.begin_connect(self.node.id)
|
||||
elif chosen is a_del:
|
||||
self.canvas.delete_node(self.node.id)
|
||||
e.accept()
|
||||
|
||||
def center(self) -> QPointF:
|
||||
return self.pos() + QPointF(_NODE_W / 2, _NODE_H / 2)
|
||||
|
||||
|
||||
class _EdgeItem(QGraphicsPathItem):
|
||||
def __init__(self, edge: Edge, canvas: "Co4ECanvas"):
|
||||
super().__init__()
|
||||
self.edge = edge
|
||||
self.canvas = canvas
|
||||
self._dst: Optional[QPointF] = None
|
||||
# Above node cards (z=2) so a connecting line is never hidden behind a
|
||||
# step; a selected node bumps itself to the front while being edited.
|
||||
self.setZValue(3)
|
||||
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
||||
self.setAcceptHoverEvents(True)
|
||||
self._hover = False
|
||||
self._apply_pen()
|
||||
|
||||
def _apply_pen(self):
|
||||
tok = current_palette()
|
||||
if self.isSelected():
|
||||
color, w = QColor(tok.accent), 3
|
||||
elif self._hover:
|
||||
color, w = QColor(tok.text_muted), 3
|
||||
else:
|
||||
color, w = QColor(tok.border_strong), 2
|
||||
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
|
||||
|
||||
def update_path(self, points):
|
||||
self._dst = points[-1] if points else None
|
||||
self.setPath(_rounded_path(points))
|
||||
|
||||
def boundingRect(self):
|
||||
return super().boundingRect().adjusted(-10, -10, 10, 10) # room for the arrowhead
|
||||
|
||||
def shape(self):
|
||||
# Widen the clickable/selectable area so a thin line is easy to grab.
|
||||
from PySide6.QtGui import QPainterPathStroker
|
||||
stroker = QPainterPathStroker()
|
||||
stroker.setWidth(14)
|
||||
return stroker.createStroke(self.path())
|
||||
|
||||
def hoverEnterEvent(self, e):
|
||||
self._hover = True
|
||||
self._apply_pen()
|
||||
self.update()
|
||||
super().hoverEnterEvent(e)
|
||||
|
||||
def hoverLeaveEvent(self, e):
|
||||
self._hover = False
|
||||
self._apply_pen()
|
||||
self.update()
|
||||
super().hoverLeaveEvent(e)
|
||||
|
||||
def paint(self, p, opt, widget=None):
|
||||
self._apply_pen()
|
||||
super().paint(p, opt, widget)
|
||||
# arrowhead at the target, pointing right into its (left) input port
|
||||
if self._dst is not None:
|
||||
p.setRenderHint(p.RenderHint.Antialiasing)
|
||||
tip = self._dst
|
||||
s = 7.0
|
||||
tri = QPolygonF([
|
||||
QPointF(tip.x() + 1, tip.y()),
|
||||
QPointF(tip.x() - s, tip.y() - s * 0.7),
|
||||
QPointF(tip.x() - s, tip.y() + s * 0.7),
|
||||
])
|
||||
col = self.pen().color()
|
||||
p.setBrush(QBrush(col))
|
||||
p.setPen(QPen(col, 1))
|
||||
p.drawPolygon(tri)
|
||||
|
||||
def contextMenuEvent(self, e):
|
||||
menu = QMenu()
|
||||
act_del = menu.addAction("🗑 Delete connection")
|
||||
if menu.exec(e.screenPos()) is act_del:
|
||||
self.canvas.delete_edge(self.edge)
|
||||
e.accept()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Widget canvas Co4E — dời khỏi ``ui/co4e_canvas.py``.
|
||||
|
||||
Vấn đề đang có: ``Co4ECanvas`` (dòng 289-701 của file cũ) một mình đã 413
|
||||
dòng — vượt trần 400 dòng/file production của CASAN Check 2 kể cả sau khi tách
|
||||
riêng ``_NodeItem``/``_EdgeItem`` (nay ở ``canvas_items.py``, xem docstring ở
|
||||
đó) và 8 hàm hình học thuần (``canvas_geometry.py``). Phần còn lại của lớp lại
|
||||
chia tiếp làm hai nhóm: mutation đồ thị (ở lại đây) và tương tác view thuần
|
||||
tuý — zoom/pan/overlay/relayout/phím tắt/kéo-thả (dời sang
|
||||
``_CanvasInteractionMixin`` ở ``canvas_interaction_mixin.py``, xem docstring
|
||||
đó về lý do và ràng buộc MRO).
|
||||
|
||||
Cách làm: cắt dán NGUYÊN VĂN dòng 289-317 (khai báo lớp + signal + hằng số zoom
|
||||
+ ``__init__``), 348-481 (load/nodes/edges + toàn bộ mutation node/edge/port-
|
||||
drag), 612-649 (status + reposition) từ ``ui/co4e_canvas.py`` — không đổi
|
||||
tên/tham số/thứ tự/logic.
|
||||
|
||||
``class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView)``: mixin đứng
|
||||
TRƯỚC ``QGraphicsView`` trong danh sách base để MRO ưu tiên các override của
|
||||
mixin (``mousePressEvent``/``mouseMoveEvent``/``mouseReleaseEvent``/
|
||||
``wheelEvent``/``resizeEvent``/``keyPressEvent``/``dragEnterEvent``/
|
||||
``dragMoveEvent``/``dropEvent``) — nếu đảo thứ tự, các override đó sẽ bị
|
||||
``QGraphicsView`` che mất và toàn bộ hành vi pan-chuột-giữa/zoom/kéo-thả sẽ
|
||||
biến mất im lặng (không lỗi, chỉ rơi lại hành vi mặc định của Qt).
|
||||
|
||||
``ui/co4e_canvas.py`` import lại ``Co4ECanvas``/``CO4E_MIME`` từ đây (không
|
||||
alias) để giữ nguyên đường import public mà các test/characterization khác
|
||||
đang dùng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QPen
|
||||
from PySide6.QtWidgets import QGraphicsPathItem, QGraphicsScene, QGraphicsView
|
||||
|
||||
from ...core.co4e import Edge, Node, Step, new_edge_id, new_node_id
|
||||
from ...theme import current_palette
|
||||
from .canvas_geometry import _ortho_path, _route
|
||||
from .canvas_interaction_mixin import _CanvasInteractionMixin
|
||||
from .canvas_items import CO4E_MIME, _NODE_H, _NODE_W, _EdgeItem, _NodeItem
|
||||
|
||||
__all__ = ["Co4ECanvas", "CO4E_MIME"]
|
||||
|
||||
|
||||
class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
|
||||
node_selected = Signal(str) # a node was clicked (→ config panel)
|
||||
node_activated = Signal(str) # double-clicked
|
||||
graph_changed = Signal() # nodes/edges/positions changed (autosave)
|
||||
|
||||
_ZOOM_MIN, _ZOOM_MAX = 0.3, 3.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("co4eCanvas") # themed frame (see theme.py)
|
||||
self._scene = QGraphicsScene(self)
|
||||
self.setScene(self._scene)
|
||||
self.setRenderHint(self.renderHints().Antialiasing)
|
||||
self.setDragMode(QGraphicsView.RubberBandDrag)
|
||||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||||
self.setAcceptDrops(True)
|
||||
self._nodes: Dict[str, _NodeItem] = {}
|
||||
self._edges: list[_EdgeItem] = []
|
||||
self._connect_from: Optional[str] = None
|
||||
self._zoom = 1.0
|
||||
self._panning = False # middle-mouse drag-to-pan
|
||||
self._pan_start = None
|
||||
self._overlay = None # bottom-left zoom/fit controls (parented to viewport)
|
||||
# manual drag-to-connect state
|
||||
self._port_src: Optional[str] = None
|
||||
self._port_src_pt: Optional[QPointF] = None
|
||||
self._temp_edge: Optional[QGraphicsPathItem] = None
|
||||
|
||||
# ---- load / serialize -------------------------------------------------
|
||||
def load(self, nodes, edges) -> None:
|
||||
self._scene.clear()
|
||||
self._nodes.clear()
|
||||
self._edges.clear()
|
||||
self._connect_from = None
|
||||
self._port_src = None
|
||||
self._temp_edge = None
|
||||
for n in nodes:
|
||||
item = _NodeItem(n, self)
|
||||
self._nodes[n.id] = item
|
||||
self._scene.addItem(item)
|
||||
for e in edges:
|
||||
if e.source in self._nodes and e.target in self._nodes:
|
||||
self._add_edge_item(e)
|
||||
self._reposition_edges()
|
||||
|
||||
def nodes(self):
|
||||
return [it.node for it in self._nodes.values()]
|
||||
|
||||
def edges(self):
|
||||
return [it.edge for it in self._edges]
|
||||
|
||||
# ---- mutation ---------------------------------------------------------
|
||||
def add_node(self, step: Step, x: float = 60.0, y: float = 60.0,
|
||||
connect_from: str = "") -> str:
|
||||
node = Node(id=new_node_id(), x=x, y=y, data=step)
|
||||
item = _NodeItem(node, self)
|
||||
self._nodes[node.id] = item
|
||||
self._scene.addItem(item)
|
||||
if connect_from and connect_from in self._nodes:
|
||||
self._make_edge(connect_from, node.id)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
self.node_selected.emit(node.id)
|
||||
return node.id
|
||||
|
||||
def add_step_below(self, node_id: str) -> None:
|
||||
"""Add the next step to the RIGHT of ``node_id`` (horizontal flow)."""
|
||||
parent = self._nodes.get(node_id)
|
||||
if parent is None:
|
||||
return
|
||||
step = Step(label="New Step")
|
||||
self.add_node(step, x=parent.node.x + _NODE_W + 150, y=parent.node.y, connect_from=node_id)
|
||||
|
||||
def _chain_tail(self) -> str:
|
||||
"""A node with no outgoing edge (so a freshly added node chains on)."""
|
||||
sources = {e.edge.source for e in self._edges}
|
||||
tails = [nid for nid in self._nodes if nid not in sources]
|
||||
return tails[-1] if tails else (next(reversed(self._nodes), "") if self._nodes else "")
|
||||
|
||||
def add_palette_step(self, step: Step, pos: QPointF) -> None:
|
||||
tail = self._chain_tail()
|
||||
self.add_node(step, x=pos.x(), y=pos.y(), connect_from=tail)
|
||||
|
||||
def begin_connect(self, source_id: str) -> None:
|
||||
self._connect_from = source_id
|
||||
|
||||
def _finish_connect(self, target_id: str) -> None:
|
||||
src = self._connect_from
|
||||
self._connect_from = None
|
||||
if src and src != target_id:
|
||||
self._make_edge(src, target_id)
|
||||
|
||||
# ---- manual drag-to-connect (from a node's output port) ---------------
|
||||
def begin_port_drag(self, source_id: str, scene_pt: QPointF) -> None:
|
||||
self._port_src = source_id
|
||||
self._port_src_pt = scene_pt
|
||||
self._temp_edge = QGraphicsPathItem()
|
||||
self._temp_edge.setZValue(3.5) # above nodes + edges while connecting
|
||||
self._temp_edge.setPen(
|
||||
QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap))
|
||||
self._scene.addItem(self._temp_edge)
|
||||
|
||||
def update_port_drag(self, scene_pt: QPointF) -> None:
|
||||
if self._temp_edge is None or self._port_src_pt is None:
|
||||
return
|
||||
self._temp_edge.setPath(_ortho_path(self._port_src_pt, scene_pt))
|
||||
|
||||
def finish_port_drag(self, scene_pt: QPointF) -> None:
|
||||
src = self._port_src
|
||||
if self._temp_edge is not None:
|
||||
self._scene.removeItem(self._temp_edge)
|
||||
self._temp_edge = None
|
||||
self._port_src = None
|
||||
self._port_src_pt = None
|
||||
tgt = self._node_at(scene_pt)
|
||||
if src and tgt and tgt != src:
|
||||
self._make_edge(src, tgt)
|
||||
|
||||
def _node_at(self, scene_pt: QPointF) -> Optional[str]:
|
||||
for it in self._scene.items(scene_pt):
|
||||
if isinstance(it, _NodeItem):
|
||||
return it.node.id
|
||||
return None
|
||||
|
||||
def _make_edge(self, source: str, target: str) -> None:
|
||||
if source == target:
|
||||
return
|
||||
if any(e.edge.source == source and e.edge.target == target for e in self._edges):
|
||||
return
|
||||
edge = Edge(id=new_edge_id(source, target), source=source, target=target)
|
||||
self._add_edge_item(edge)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def _add_edge_item(self, edge: Edge) -> None:
|
||||
item = _EdgeItem(edge, self)
|
||||
self._edges.append(item)
|
||||
self._scene.addItem(item)
|
||||
|
||||
def delete_edge(self, edge: Edge) -> None:
|
||||
for e in list(self._edges):
|
||||
if e.edge is edge or (e.edge.source == edge.source and e.edge.target == edge.target):
|
||||
self._scene.removeItem(e)
|
||||
self._edges.remove(e)
|
||||
self.graph_changed.emit()
|
||||
|
||||
def delete_node(self, node_id: str) -> None:
|
||||
item = self._nodes.pop(node_id, None)
|
||||
if item is None:
|
||||
return
|
||||
self._scene.removeItem(item)
|
||||
for e in list(self._edges):
|
||||
if e.edge.source == node_id or e.edge.target == node_id:
|
||||
self._scene.removeItem(e)
|
||||
self._edges.remove(e)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def delete_selected(self) -> None:
|
||||
for nid in [it.node.id for it in self._nodes.values() if it.isSelected()]:
|
||||
self.delete_node(nid)
|
||||
for e in [it.edge for it in self._edges if it.isSelected()]:
|
||||
self.delete_edge(e)
|
||||
|
||||
def update_node_status(self, node_id: str, status: str) -> None:
|
||||
item = self._nodes.get(node_id)
|
||||
if item is not None:
|
||||
item.status = status
|
||||
item.update()
|
||||
|
||||
def reset_statuses(self) -> None:
|
||||
for it in self._nodes.values():
|
||||
it.status = "idle"
|
||||
it.update()
|
||||
|
||||
def refresh_node(self, node_id: str) -> None:
|
||||
item = self._nodes.get(node_id)
|
||||
if item is not None:
|
||||
item.update()
|
||||
|
||||
def _node_rects(self, exclude):
|
||||
"""Rectangles of every node except ``exclude`` (inflated a little), used
|
||||
as obstacles the edge router steers around."""
|
||||
m = 12.0
|
||||
out = []
|
||||
for nid, item in self._nodes.items():
|
||||
if nid in exclude:
|
||||
continue
|
||||
p = item.pos()
|
||||
out.append(QRectF(p.x(), p.y(), _NODE_W, _NODE_H).adjusted(-m, -m, m, m))
|
||||
return out
|
||||
|
||||
def _reposition_edges(self) -> None:
|
||||
for e in self._edges:
|
||||
s = self._nodes.get(e.edge.source)
|
||||
t = self._nodes.get(e.edge.target)
|
||||
if s is None or t is None:
|
||||
continue
|
||||
src = s.pos() + QPointF(_NODE_W, _NODE_H / 2) # right-center (output)
|
||||
dst = t.pos() + QPointF(0, _NODE_H / 2) # left-center (input)
|
||||
obstacles = self._node_rects({e.edge.source, e.edge.target})
|
||||
e.update_path(_route(src, dst, obstacles))
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Khu vực CHAT của Co4E (composer + autocomplete ``/skill:``/``/agent:``) —
|
||||
tách khỏi ``ui/co4e_tab.py``.
|
||||
|
||||
Vấn đề đang có: 3 hàm module-level (``_skill_names``/``_agent_names``/
|
||||
``_directive_token``), lớp ``_ChatInput`` (ô chat có popup autocomplete) và
|
||||
phần DỰNG WIDGET của ``Co4ETab._build_chat`` (nguyên bản ở ``ui/co4e_tab.py``
|
||||
dòng 63-73, 124-228 và 1030-1093) nằm rải trong file container 2000+ dòng —
|
||||
vượt xa giới hạn CASAN (≤400 dòng mỗi file production) và không tách được
|
||||
riêng để test mà không phải dựng cả ``Co4ETab``. Không phần nào trong số này
|
||||
đọc/ghi trạng thái RIÊNG của ``Co4ETab`` lúc DỰNG (``_flow_logs`` là ngoại lệ —
|
||||
xem chú thích ở ``ChatPanel`` bên dưới), nên tách được thành các hàm/lớp con
|
||||
độc lập.
|
||||
|
||||
Cách làm: dời nguyên 3 hàm + ``_ChatInput`` — KHÔNG đổi tên, KHÔNG đổi hành vi
|
||||
(kể cả các quirk trông như bug, xem docstring của ``tests/characterization/
|
||||
test_co4e_chat_view.py``: agent chèn nguyên tên KHÔNG slugify còn skill có,
|
||||
dedup theo tên hiển thị không theo slug, Enter có hai hành vi tuỳ popup còn
|
||||
hiện hay đã ẩn, ...). Phần dựng widget của ``_build_chat`` được bọc vào một
|
||||
lớp mới ``ChatPanel(QWidget)`` theo đúng khuôn mẫu đã dùng cho
|
||||
``AgentListPanel``/``SkillsListPanel``/``RunsPagePanel`` (xem
|
||||
``presentation/co4e/agent_list_panel.py``): panel chỉ dựng cấu trúc UI, KHÔNG
|
||||
tự nối signal (``ui/co4e_tab.py`` mới là nơi biết ``_toggle_messages``/
|
||||
``_chat_send`` là gì) và KHÔNG tự tạo ``_flow_logs`` (dict per-flow ChatView —
|
||||
đó là STATE của ``Co4ETab``, ghi bởi ``_ensure_flow_log``/``_active_log`` nằm
|
||||
ngoài phạm vi panel này). Tên thuộc tính giữ NGUYÊN so với bản gốc
|
||||
(``msgs_icon``, ``msgs_title``, ``chat_toggle_btn``, ``chat_stack``,
|
||||
``chat_input_row``, ``chat_input``, ``chat_send_btn``, ``co4e_routing_toggle``)
|
||||
vì bị tham chiếu ở rất nhiều nơi khác của ``Co4ETab`` (``_toggle_messages``,
|
||||
``_chat_send``, ``_refresh_usage_total``, ...) — đổi tên sẽ buộc phải sửa mọi
|
||||
chỗ đó, vượt phạm vi lượt tách này. Riêng ``_mhdr`` (biến cục bộ đặt tên riêng
|
||||
lẻ, không theo quy ước công khai) đổi thành ``.header`` và ``_usage_total_lbl``
|
||||
đổi thành ``.usage_total_lbl`` — cả hai an toàn vì bản gốc chỉ dùng nội bộ
|
||||
``_build_chat``/``_toggle_messages`` (đã kiểm bằng grep toàn file), và
|
||||
``ui/co4e_tab.py`` sau khi tách vẫn gán lại các tên cũ (``self._mhdr``,
|
||||
``self._usage_total_lbl``) làm alias trỏ vào hai thuộc tính công khai này, nên
|
||||
mọi chỗ dùng tên cũ trên ``Co4ETab`` không phải sửa.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton,
|
||||
QStackedWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...core.co4e_builtins import BUILTIN_AGENTS
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.icons import icon
|
||||
from ...ui.routing_toggle import RoutingToggle
|
||||
|
||||
|
||||
def _skill_names() -> List[str]:
|
||||
try:
|
||||
return [s.name for s in skills_mod.list_skills() + skills_mod.builtin_skills()]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
def _agent_names() -> List[str]:
|
||||
names = [a.name for a in co4e.list_custom_agents()]
|
||||
names += [a.name for a in BUILTIN_AGENTS if a.name not in names]
|
||||
return names
|
||||
|
||||
|
||||
def _directive_token(text: str, pos: int):
|
||||
"""Locate a ``/skill[:x]`` or ``/agent[:x]`` directive the cursor is on,
|
||||
anywhere in the line. Returns ``(start, kind, partial)`` or ``None``."""
|
||||
before = text[:pos]
|
||||
start = re.search(r"\S*$", before).start()
|
||||
token = before[start:]
|
||||
m = re.match(r"^/(skill|agent):?([\w\-.]*)$", token)
|
||||
if m:
|
||||
return start, m.group(1), m.group(2)
|
||||
for kind in ("skill", "agent"):
|
||||
if len(token) >= 2 and ("/" + kind).startswith(token):
|
||||
return start, kind, ""
|
||||
return None
|
||||
|
||||
|
||||
class _ChatInput(QLineEdit):
|
||||
"""Chat box with ``/skill:`` and ``/agent:`` autocomplete (parity with the
|
||||
Cowork composer). The popup never grabs focus, so typing keeps flowing."""
|
||||
|
||||
submit = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._popup = QListWidget()
|
||||
self._popup.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
|
||||
| Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
|
||||
self._popup.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
self._popup.setFocusPolicy(Qt.NoFocus)
|
||||
self._popup.itemClicked.connect(lambda _i: self._accept())
|
||||
self.textEdited.connect(self._maybe_popup)
|
||||
|
||||
def _maybe_popup(self, *_a) -> None:
|
||||
tok = _directive_token(self.text(), self.cursorPosition())
|
||||
if tok is None:
|
||||
self._popup.hide()
|
||||
return
|
||||
_start, kind, partial = tok
|
||||
f = partial.lower()
|
||||
self._popup.clear()
|
||||
if kind == "skill":
|
||||
for name in _skill_names():
|
||||
if f in name.lower():
|
||||
self._add_row(name, f"/skill:{co4e.slugify(name)} ", name)
|
||||
else:
|
||||
for name in _agent_names():
|
||||
if f in name.lower():
|
||||
self._add_row(name, f"/agent:{name} ", name)
|
||||
if self._popup.count() == 0:
|
||||
self._popup.hide()
|
||||
return
|
||||
self._popup.setCurrentRow(0)
|
||||
rows = min(7, self._popup.count())
|
||||
h = 8 + rows * 22
|
||||
self._popup.resize(max(280, self.width()), h)
|
||||
tl = self.mapToGlobal(self.rect().topLeft())
|
||||
self._popup.move(tl.x(), tl.y() - h - 2)
|
||||
self._popup.show()
|
||||
|
||||
def _add_row(self, label: str, replacement: str, tip: str) -> None:
|
||||
it = QListWidgetItem(label)
|
||||
it.setData(Qt.UserRole, replacement)
|
||||
it.setToolTip(tip)
|
||||
self._popup.addItem(it)
|
||||
|
||||
def _accept(self) -> None:
|
||||
item = self._popup.currentItem()
|
||||
self._popup.hide()
|
||||
if item is None:
|
||||
return
|
||||
replacement = item.data(Qt.UserRole)
|
||||
tok = _directive_token(self.text(), self.cursorPosition())
|
||||
start = tok[0] if tok else self.cursorPosition()
|
||||
pos = self.cursorPosition()
|
||||
full = self.text()
|
||||
new_text = full[:start] + replacement + full[pos:]
|
||||
self.setText(new_text)
|
||||
self.setCursorPosition(start + len(replacement))
|
||||
self.setFocus()
|
||||
|
||||
def focusOutEvent(self, e): # noqa: N802
|
||||
if not self._popup.underMouse():
|
||||
self._popup.hide()
|
||||
super().focusOutEvent(e)
|
||||
|
||||
def keyPressEvent(self, e): # noqa: N802
|
||||
if self._popup.isVisible():
|
||||
k = e.key()
|
||||
n = self._popup.count()
|
||||
if k in (Qt.Key_Down, Qt.Key_Up) and n:
|
||||
step = 1 if k == Qt.Key_Down else -1
|
||||
self._popup.setCurrentRow((self._popup.currentRow() + step) % n)
|
||||
return
|
||||
if k in (Qt.Key_Tab,):
|
||||
self._accept()
|
||||
return
|
||||
if k == Qt.Key_Escape:
|
||||
self._popup.hide()
|
||||
return
|
||||
if k in (Qt.Key_Return, Qt.Key_Enter):
|
||||
self._accept()
|
||||
return
|
||||
if e.key() in (Qt.Key_Return, Qt.Key_Enter):
|
||||
self.submit.emit()
|
||||
return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
|
||||
class ChatPanel(QWidget):
|
||||
"""Widget khu vực CHAT của Co4E: header "Messages" + ``chat_stack`` (một
|
||||
``ChatView`` mỗi flow) + composer (ô chat + routing toggle + nút gửi).
|
||||
|
||||
Vai trò: một widget con thuần ở tầng presentation, chỉ dựng cấu trúc UI
|
||||
(đúng những gì ``ui/co4e_tab.py`` dòng 1030-1093 làm trước đây), không biết
|
||||
gì về ``Co4ETab``/``_toggle_messages``/``_chat_send``. Bên gọi (hiện là
|
||||
``Co4ETab``) tự đọc các thuộc tính công khai dưới đây để nối signal và nạp
|
||||
dữ liệu — panel không tự làm hộ, để giữ đúng ranh giới "một nơi một việc"
|
||||
đã dùng cho ``AgentListPanel``/``SkillsListPanel``/``RunsPagePanel``.
|
||||
|
||||
KHÔNG tự tạo ``_flow_logs``: dict ``{wf_id: ChatView}`` là STATE của
|
||||
``Co4ETab`` (ghi bởi ``_ensure_flow_log``, đọc bởi ``_active_log``/
|
||||
``chat_log``) — panel chỉ dựng cái ``chat_stack`` (vỏ chứa) rỗng, việc nạp
|
||||
từng ``ChatView`` vào đó khi có flow mới vẫn ở ``Co4ETab``.
|
||||
|
||||
Panel TỰ đặt trạng thái hiển thị mặc định là COLLAPSED (chỉ header hiện,
|
||||
thân chat ẩn) ngay trong ``__init__`` — đây là phần "hình dạng lúc mới
|
||||
dựng" của chính panel, khác với ``_msgs_collapsed``/``_vsplit_sizes`` (cờ +
|
||||
kích thước để khôi phục splitter khi mở lại) vẫn là STATE của ``Co4ETab``
|
||||
vì chỉ ``_toggle_messages`` (ở lại ``Co4ETab``, đọc ``self._vsplit`` của cả
|
||||
tab) mới dùng tới.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx) -> None:
|
||||
super().__init__()
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
# "Messages" header at the TOP, above the chat box. Toggling it shows or
|
||||
# hides the WHOLE chat box (message list + composer) below it.
|
||||
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.chat_toggle_btn = QPushButton()
|
||||
self.chat_toggle_btn.setObjectName("msgToggle")
|
||||
self.chat_toggle_btn.setFlat(True)
|
||||
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → points up (click to expand)
|
||||
self.chat_toggle_btn.setFixedSize(22, 22)
|
||||
# KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao
|
||||
# xu ly (_toggle_messages) - panel chi dung widget, khong biet no la gi.
|
||||
mh.addWidget(self.msgs_icon)
|
||||
mh.addWidget(self.msgs_title)
|
||||
mh.addStretch(1)
|
||||
mh.addWidget(self.chat_toggle_btn)
|
||||
lay.addWidget(self.header) # header on top
|
||||
# Point-conversation (message bubbles) like Cowork, not a flat textbox.
|
||||
# ONE ChatView PER FLOW (keyed by workflow id) inside a stack, so each flow
|
||||
# tab has its OWN separate conversation and they never bleed into each other.
|
||||
self.chat_stack = QStackedWidget()
|
||||
lay.addWidget(self.chat_stack, 1)
|
||||
self.chat_input_row = QWidget()
|
||||
crow = QVBoxLayout(self.chat_input_row)
|
||||
crow.setContentsMargins(0, 4, 0, 0); crow.setSpacing(3)
|
||||
# Running conversation token/cost TOTAL for this flow (↓in ↑out ▤ctx
|
||||
# $cost) at the bottom, exactly like Cowork's conversation total.
|
||||
self.usage_total_lbl = QLabel("")
|
||||
self.usage_total_lbl.setObjectName("hint")
|
||||
self.usage_total_lbl.setStyleSheet(
|
||||
f"color: {current_palette().text_faint}; font-size: 11px;")
|
||||
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"))
|
||||
# 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"))
|
||||
# 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").
|
||||
self.co4e_routing_toggle = RoutingToggle(ctx, "co4e")
|
||||
row.addWidget(self.co4e_routing_toggle)
|
||||
row.addWidget(self.chat_send_btn)
|
||||
crow.addWidget(_inp)
|
||||
lay.addWidget(self.chat_input_row)
|
||||
# Default = COLLAPSED: only the "Messages" header shows; the chat box is
|
||||
# hidden and the canvas gets the room until the user expands it.
|
||||
self.chat_stack.hide()
|
||||
self.chat_input_row.hide()
|
||||
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
|
||||
self.setMaximumHeight(self.header.sizeHint().height() + 6)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Panel trang "Runs" (danh sách các lần chạy flow) của Co4E — tách khỏi
|
||||
``ui/co4e_tab.py``.
|
||||
|
||||
Vấn đề đang có: giống ``AgentListPanel``/``SkillsListPanel`` (xem
|
||||
``presentation/co4e/agent_list_panel.py``/``presentation/co4e/skills_list_panel.py``),
|
||||
đoạn dựng widget trang "Runs" (nguyên bản ở ``ui/co4e_tab.py``, method
|
||||
``_build_runs_page``, dòng 869-928) nằm nguyên trong thân một method của
|
||||
``Co4ETab``. Đoạn này chỉ tạo ``QWidget``/``QPushButton``/``QLabel``/
|
||||
``QTableWidget`` + layout bọc — không đọc/ghi trạng thái nào của ``Co4ETab``
|
||||
khi DỰNG (chỉ khi người dùng bấm nút mới cần tới
|
||||
``_show_runs``/``_open_workspace_folder``/``_stop_selected_run``/... của
|
||||
``Co4ETab``) — nên tách được thành một ``QWidget`` con độc lập.
|
||||
|
||||
Cách làm: dời nguyên phần dựng widget sang đây, KHÔNG đổi tên thuộc tính so
|
||||
với bản gốc ngoại trừ đổi TÊN THUỘC TÍNH cho khớp quy ước panel công khai
|
||||
(``runs_back_btn`` → ``back_btn``, ``runs_title`` → ``title_label``,
|
||||
``run_stop_btn`` → ``stop_btn``, ``run_rename_btn`` → ``rename_btn``,
|
||||
``run_del_btn`` → ``del_btn``, ``run_clear_btn`` → ``clear_btn``,
|
||||
``runs_table`` → ``table``); riêng ``ws_folder_btn`` GIỮ NGUYÊN TÊN vì
|
||||
``ui/co4e_tab.py`` (dòng ~1669) còn chỗ kiểm ``hasattr(self, "ws_folder_btn")``
|
||||
— đổi tên sẽ làm nhánh đó không còn nhận ra thuộc tính này. Giá trị/thứ tự
|
||||
dựng widget giữ y hệt bản gốc.
|
||||
|
||||
Panel KHÔNG tự nối bất kỳ signal nào (``.clicked``/``.itemDoubleClicked``/
|
||||
``.customContextMenuRequested``) và KHÔNG tự gọi ``_refresh_ws_folder_btn()``
|
||||
— theo đúng nguyên tắc "một việc rẽ ra một lần" đã dùng cho
|
||||
``AgentListPanel``/``SkillsListPanel``: việc dựng widget (ở đây) tách khỏi
|
||||
việc nối hành vi (vẫn ở ``Co4ETab``, nơi biết ``_stop_selected_run``/
|
||||
``_rename_selected_run``/``_delete_selected_run``/``_open_run_from_table``/
|
||||
``_open_workspace_folder``/``_runs_context_menu``/``_refresh_ws_folder_btn``
|
||||
là gì). Gộp hai việc đó vào panel sẽ buộc panel phải biết về ``Co4ETab``, xoá
|
||||
mất lý do tách nó ra.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QHeaderView, QLabel, QPushButton, QTableWidget, QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon
|
||||
|
||||
|
||||
class RunsPagePanel(QWidget):
|
||||
"""Widget trang "Runs" của Co4E: thanh tiêu đề + hàng nút thao tác + bảng.
|
||||
|
||||
Vai trò: một widget con thuần ở tầng presentation, chỉ dựng cấu trúc UI
|
||||
(đúng những gì ``ui/co4e_tab.py`` dòng 869-928 làm trước đây), không biết
|
||||
gì về ``Co4ETab``/``_show_runs``/``_stop_selected_run``/... Bên gọi (hiện
|
||||
là ``Co4ETab``) tự đọc 8 thuộc tính công khai dưới đây để nối signal, gọi
|
||||
``_refresh_ws_folder_btn()`` và nạp dữ liệu — panel không tự làm hộ, để
|
||||
giữ đúng ranh giới "một nơi một việc" đã dùng cho ``AgentListPanel``/
|
||||
``SkillsListPanel``.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
v = QVBoxLayout(self)
|
||||
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"))
|
||||
self.back_btn.setIcon(icon("chevron-left"))
|
||||
self.back_btn.setToolTip(tr("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.setObjectName("hint")
|
||||
hdr.addWidget(self.title_label)
|
||||
# Show + open the workspace folder where flow outputs land (below the tab,
|
||||
# next to the title) so the files a flow produced are easy to find.
|
||||
self.ws_folder_btn = QPushButton()
|
||||
self.ws_folder_btn.setIcon(icon("folder"))
|
||||
self.ws_folder_btn.setFlat(True)
|
||||
self.ws_folder_btn.setCursor(Qt.PointingHandCursor)
|
||||
# KHONG noi .clicked va KHONG tu goi _refresh_ws_folder_btn() o day:
|
||||
# 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.setIcon(icon("stop"))
|
||||
self.stop_btn.setObjectName("danger")
|
||||
self.stop_btn.setToolTip(tr("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.setIcon(icon("edit"))
|
||||
self.rename_btn.setToolTip(tr("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.setIcon(icon("trash"))
|
||||
self.del_btn.setToolTip(tr("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"))
|
||||
# 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.)
|
||||
hdr.addWidget(self.stop_btn)
|
||||
hdr.addWidget(self.rename_btn)
|
||||
hdr.addWidget(self.del_btn)
|
||||
hdr.addWidget(self.clear_btn)
|
||||
v.addLayout(hdr)
|
||||
self.table = QTableWidget(0, 5)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.table.setToolTip(tr("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)
|
||||
# KHONG noi .customContextMenuRequested o day: cung ly do nhu tren.
|
||||
v.addWidget(self.table, 1)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Factory dựng tab Co4E Studio cho bootstrap.py — điểm nối duy nhất giữa
|
||||
lớp Qt cũ (``cowork_local.ui.co4e_tab.Co4ETab``, đang chờ tách nhỏ) và phần
|
||||
lắp ráp ứng dụng.
|
||||
|
||||
Vì sao có file này dù chưa tách xong widget con nào: quy ước #2 của team là
|
||||
"nộp factory, không tự lắp vào app" — người giữ bootstrap.py (Nam, N1) cần
|
||||
chốt được chữ ký sớm (hạn lắp 28/08) trong khi phần thân bên trong Co4E Studio
|
||||
vẫn còn đang được tách dần sang presentation/co4e/*.py và
|
||||
application/workflows/co4e_workflow_service.py.
|
||||
|
||||
Chữ ký ``build_co4e_tab(ctx, workflow_service)`` KHÔNG có default cho
|
||||
``workflow_service``: nếu cho default None, lúc bootstrap.py gọi thiếu tham số
|
||||
vẫn hợp lệ cú pháp, dựng ra tab không có service, và lỗi chỉ nổ muộn bên trong
|
||||
widget khi người dùng bấm Run — thay vì nổ ngay tại dòng lắp ráp. Chưa có
|
||||
service thật thì bên gọi tự truyền fake (xem tests/fakes/fake_co4e_workflow_service.py).
|
||||
|
||||
QUAN TRỌNG — đây KHÔNG phải bản cuối: thân hàm hiện tại chỉ bọc nguyên
|
||||
``Co4ETab`` cũ 1:1 và CHƯA dùng đến ``workflow_service``. Chữ ký thì giữ
|
||||
nguyên — đó là hợp đồng với bootstrap.py.
|
||||
|
||||
Cập nhật 25/08 — cả 6 widget con (skills/agent list, canvas, node property,
|
||||
run control, chat view) ĐÃ tách xong khỏi ``ui/co4e_tab.py`` và ``Co4ETab``
|
||||
NỘI BỘ đã lắp ráp lại từ các panel mới đó (xem ``ui/co4e_tab.py``:
|
||||
``_build_sidebar``/``_build_runs_page``/``_build_chat``) — phần "lắp ráp từ
|
||||
widget đã tách" coi như xong. PHẦN CÒN LẠI — đổi ``Co4ETab`` để thật sự dùng
|
||||
``workflow_service`` thay cho ``core/co4e_run_manager.py::Co4ERunManager`` nội
|
||||
bộ — ĐÃ QUYẾT ĐỊNH HOÃN LẠI thành một task riêng, không làm chung với việc
|
||||
tách widget: ``self.manager`` (``Co4ERunManager``) bị dùng ở 24 chỗ trong
|
||||
``Co4ETab``, và khác với các bước tách widget (chỉ động tới phần DỰNG UI),
|
||||
việc đổi sang ``Co4EWorkflowService`` đòi phải (1) viết một adapter Qt thật
|
||||
(``WorkflowRunner``) bọc ``AgentWorker``/``QThread`` — hiện chưa tồn tại, và
|
||||
(2) sửa mọi chỗ đọc ``RunHandle.wf`` như một đối tượng ``Workflow`` (ví dụ
|
||||
``ui/co4e_tab.py`` dòng ~1330: ``h.wf.nodes``) thành đọc dict thô
|
||||
(``RunRecord.wf``) — tức là chạm trực tiếp vào đúng luồng gọi AI thật/QThread
|
||||
mà mọi bước tách widget trước đó đã cố tình né. Trước khi đổi, cần lưới an
|
||||
toàn riêng (characterization đầy đủ cho ``Co4ERunManager``) — xem
|
||||
``tests/characterization/test_co4e_run_manager_behavior.py`` hiện có cho một
|
||||
phần hành vi, chưa phủ hết 24 điểm gọi này.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
def build_co4e_tab(ctx, workflow_service) -> QWidget:
|
||||
"""Factory tạo tab Co4E Studio.
|
||||
|
||||
Vai trò: hàm lắp ráp ở tầng presentation, là API ổn định mà
|
||||
bootstrap.py gọi để lấy widget tab Co4E — không phải nơi chứa logic.
|
||||
Logic thật vẫn nằm ở ``cowork_local.ui.co4e_tab.Co4ETab`` cho tới khi
|
||||
được tách hết sang các module trong presentation/co4e/.
|
||||
|
||||
``workflow_service`` chưa được dùng ở bản này (Co4ETab cũ tự quản lý
|
||||
state qua Co4ERunManager nội bộ). Tham số vẫn bắt buộc ngay từ bây giờ
|
||||
để chữ ký không phải đổi ở lượt tách kế tiếp — chỉ thân hàm đổi.
|
||||
"""
|
||||
# Import trong thân hàm, không ở đầu module: ui/co4e_tab.py hiện kéo theo
|
||||
# toàn bộ cây widget Co4E Studio cũ (canvas, run manager, chat view...).
|
||||
# Đặt ở đây để module factory này nhẹ khi bootstrap.py chỉ cần đọc chữ ký/
|
||||
# import hàm mà chưa gọi nó — chi phí load Qt widget nặng chỉ trả khi
|
||||
# build_co4e_tab() thực sự được gọi. Không phải để né circular import
|
||||
# (ui/co4e_tab.py không import ngược presentation/co4e/).
|
||||
from ...ui.co4e_tab import Co4ETab
|
||||
|
||||
return Co4ETab(ctx)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Các hành động (sub-agent/attachment/AI-draft/load-models) của
|
||||
``StepConfigPanel`` — tách khỏi ``ui/co4e_config_panel.py`` (nguyên bản dòng
|
||||
380-528) để ``presentation/co4e/node_property_panel.py`` không vượt trần 400
|
||||
dòng của CASAN Check 2.
|
||||
|
||||
Vấn đề đang có: ``StepConfigPanel`` gộp cả việc dựng UI (``__init__``) lẫn 8
|
||||
hành động phụ trợ (thêm/sửa/xoá sub-agent, thêm/xoá attachment, soạn hướng dẫn
|
||||
bằng AI, tải danh sách model) trong cùng một class 396 dòng — vượt trần nếu
|
||||
để nguyên một file. Support cắt riêng phần hành động ra một ``mixin`` là hợp
|
||||
lý vì các method này CHỈ đọc/ghi trạng thái đã có sẵn trên ``self`` do
|
||||
``StepConfigPanel.__init__`` định nghĩa (``self._step``, ``self._node_id``,
|
||||
``self.ctx``, ``self.sub_list``, ``self.attach_list``,
|
||||
``self.instructions_edit``, ``self.gen_btn``, ``self.model_combo``,
|
||||
``self.load_models_btn``) — không có state/``__init__`` riêng của mixin.
|
||||
|
||||
Cách làm: dời NGUYÊN VĂN 8 method (``_available_agent_names``,
|
||||
``_add_subagent``, ``_edit_subagent``, ``_del_subagent``, ``_add_attachment``,
|
||||
``_del_attachment``, ``_ai_draft``, ``_load_models``) vào class MỚI
|
||||
``_StepConfigActionsMixin``. Không đổi tên, không đổi thứ tự tham số, không
|
||||
gộp/tách hàm nào bên trong. ``node_property_panel.py`` ghép mixin này với
|
||||
``QScrollArea`` qua đa kế thừa (``class StepConfigPanel(_StepConfigActionsMixin,
|
||||
QScrollArea)``) — không có method nào ở đây trùng tên với ``QScrollArea`` nên
|
||||
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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtWidgets import QInputDialog, QListWidgetItem
|
||||
|
||||
from ...core.co4e import SubAgent
|
||||
from ...i18n import tr
|
||||
|
||||
|
||||
class _StepConfigActionsMixin:
|
||||
"""Mixin THUẦN (không ``__init__`` riêng) chứa các hành động phụ trợ của
|
||||
``StepConfigPanel``. Vai trò: giữ ``node_property_panel.py`` gọn dưới 400
|
||||
dòng bằng cách tách phần "hành động" (nghiệp vụ khi bấm nút) ra khỏi phần
|
||||
"dựng UI" (``__init__``/``load_step``), trong khi vẫn nằm cùng tầng
|
||||
``presentation`` — các method này thao tác trực tiếp widget Qt
|
||||
(``QInputDialog``, ``QFileDialog``, danh sách Qt) nên không hạ được xuống
|
||||
``application``/``domain`` (nơi cấm import PySide6) mà không viết lại
|
||||
logic, việc đó ngoài phạm vi của lượt tách này.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _available_agent_names() -> List[str]:
|
||||
"""Agents the user can pick as a parallel sub-agent: their own custom
|
||||
agents first, then the built-in personas (kept for resolution even
|
||||
though they're no longer in the palette)."""
|
||||
from ...core import co4e
|
||||
from ...core.co4e_builtins import BUILTIN_AGENTS
|
||||
|
||||
names = [a.name for a in co4e.list_custom_agents()]
|
||||
names += [a.name for a in BUILTIN_AGENTS if a.name not in names]
|
||||
return names
|
||||
|
||||
def _add_subagent(self) -> None:
|
||||
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
|
||||
else:
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
self._step.sub_agents.append(SubAgent(agent=name))
|
||||
self.sub_list.addItem(name)
|
||||
self.changed.emit()
|
||||
|
||||
def _edit_subagent(self, item) -> None:
|
||||
"""Double-click a sub-agent row → re-pick from the list."""
|
||||
if self._step is None:
|
||||
return
|
||||
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 = (name or "").strip()
|
||||
if ok and name:
|
||||
self._step.sub_agents[row].agent = name
|
||||
item.setText(name)
|
||||
self.changed.emit()
|
||||
|
||||
def _del_subagent(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.sub_list.currentRow()
|
||||
if 0 <= row < len(self._step.sub_agents):
|
||||
self._step.sub_agents.pop(row)
|
||||
self.sub_list.takeItem(row)
|
||||
self.changed.emit()
|
||||
|
||||
def _add_attachment(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
from pathlib import Path as _P
|
||||
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("co4e.attach_add"))
|
||||
for f in files:
|
||||
if f and f not in self._step.attachments:
|
||||
self._step.attachments.append(f)
|
||||
item = QListWidgetItem(_P(f).name)
|
||||
item.setToolTip(f)
|
||||
self.attach_list.addItem(item)
|
||||
if files:
|
||||
self.changed.emit()
|
||||
|
||||
def _del_attachment(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.attach_list.currentRow()
|
||||
if 0 <= row < len(self._step.attachments):
|
||||
self._step.attachments.pop(row)
|
||||
self.attach_list.takeItem(row)
|
||||
self.changed.emit()
|
||||
|
||||
def _ai_draft(self) -> None:
|
||||
"""Draft this step's instructions from its label (name) + role — first
|
||||
asking for an optional description so the generated instructions can be
|
||||
more specific/detailed than name+role alone would produce."""
|
||||
if self.ctx is None or self._step is None:
|
||||
return
|
||||
from ...core.worker import AgentWorker
|
||||
|
||||
name = self.label_edit.text().strip()
|
||||
role = self.role_edit.text().strip()
|
||||
if not name and not role:
|
||||
return
|
||||
hint, ok = QInputDialog.getMultiLineText(
|
||||
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
|
||||
if not ok:
|
||||
return
|
||||
hint = hint.strip()
|
||||
self.gen_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ...core.ai_task_planner import generate_agent_prompt
|
||||
return {"text": generate_agent_prompt(ctx.build_active_provider(), name, role, hint,
|
||||
cancel=worker.is_cancelled)}
|
||||
|
||||
def done(result: dict):
|
||||
self.gen_btn.setEnabled(True)
|
||||
if result.get("text"):
|
||||
self.instructions_edit.setPlainText(result["text"]) # _on_edit persists it
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda _e: self.gen_btn.setEnabled(True))
|
||||
self._draft_worker = w
|
||||
w.start()
|
||||
|
||||
def _load_models(self) -> None:
|
||||
if self.ctx is None:
|
||||
return
|
||||
from ...core import preview_ai
|
||||
from ...core.worker import AgentWorker
|
||||
|
||||
self.load_models_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_w):
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict):
|
||||
self.load_models_btn.setEnabled(True)
|
||||
models = []
|
||||
for lst in (result or {}).values():
|
||||
models.extend(lst)
|
||||
cur = self.model_combo.currentText()
|
||||
self.model_combo.blockSignals(True)
|
||||
self.model_combo.clear()
|
||||
self.model_combo.addItems(sorted(set(models)))
|
||||
self.model_combo.setEditText(cur)
|
||||
self.model_combo.blockSignals(False)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda _e: self.load_models_btn.setEnabled(True))
|
||||
self._model_worker = w
|
||||
w.start()
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Panel bên phải chỉnh sửa persona của một step đang chọn trên canvas Co4E —
|
||||
tách khỏi ``ui/co4e_config_panel.py`` (nguyên bản dòng 1-27, 133-378).
|
||||
|
||||
Vấn đề đang có: cả ``StepConfigPanel`` (dựng UI + 8 hành động phụ trợ) và
|
||||
khung section gấp/mở dùng chung của nó nằm trong một file 528 dòng — vượt
|
||||
trần 400 dòng của CASAN Check 2 nếu tách nguyên khối. Chia thành 3 file theo
|
||||
trách nhiệm: ``step_config_section.py`` (khung ▶/▼ dùng chung, không có hành
|
||||
vi nghiệp vụ riêng), ``node_property_actions_mixin.py`` (8 hành động: thêm/
|
||||
sửa/xoá sub-agent, thêm/xoá attachment, soạn AI, tải model — chỉ đọc/ghi state
|
||||
đã có sẵn trên ``self``), và file này (``StepConfigPanel`` — 4 Signal,
|
||||
``__init__`` dựng toàn bộ form, ``load_step``/``clear_step`` nạp/xoá dữ liệu,
|
||||
``_on_edit`` ghi field vào ``Step``).
|
||||
|
||||
Cách làm: dời NGUYÊN VĂN phần class (Signal + ``__init__`` + ``load_step`` +
|
||||
``clear_step`` + ``_on_edit``, nguyên bản dòng 133-378) sang đây, không đổi
|
||||
tên thuộc tính/tham số, không đổi thứ tự dựng widget, không đổi giá trị mặc
|
||||
định nào. ``StepConfigPanel`` giờ kế thừa thêm ``_StepConfigActionsMixin``
|
||||
(``class StepConfigPanel(_StepConfigActionsMixin, QScrollArea)``) để có lại
|
||||
các method đã dời sang ``node_property_actions_mixin.py`` — không có method
|
||||
nào của mixin trùng tên với ``QScrollArea`` nên thứ tự kế thừa mixin-trước
|
||||
không phải là bắt buộc như ở ``co4e_canvas_widget.py``, chỉ giữ để nhất quán
|
||||
quy ước đặt mixin trước base Qt.
|
||||
|
||||
Import ``PROVIDER_LABELS`` (nguyên bản dòng 21) hiện KHÔNG được dùng ở đâu
|
||||
trong phần class đã dời (đã xác minh bằng grep trên toàn bộ
|
||||
``ui/co4e_config_panel.py`` gốc) — vẫn giữ nguyên import này y hệt bản gốc,
|
||||
KHÔNG xoá dù có vẻ thừa, để đúng phạm vi "chỉ dời chỗ" của lượt tách này.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
||||
QListWidgetItem, QPlainTextEdit, QPushButton, QScrollArea, QSpinBox,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ...config import PROVIDER_LABELS
|
||||
from ...core.co4e import PERMISSION_PRESETS, Step
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon, icon_picker_combo
|
||||
from .node_property_actions_mixin import _StepConfigActionsMixin
|
||||
from .step_config_section import _add_section
|
||||
|
||||
|
||||
class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
changed = Signal() # any field edited → repaint node + autosave
|
||||
run_node = Signal(str) # "Run this step" (node id)
|
||||
run_from = Signal(str) # "Run from here"
|
||||
delete_node = Signal(str) # "Delete step"
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._step: Optional[Step] = None
|
||||
self._node_id = ""
|
||||
self._loading = False
|
||||
self.setWidgetResizable(True)
|
||||
host = QWidget()
|
||||
self.setWidget(host)
|
||||
outer = QVBoxLayout(host)
|
||||
outer.setSpacing(1)
|
||||
|
||||
# Grouped sections stacked on one scrolling page — same fields as
|
||||
# before, grouped by what they're for: identity, execution
|
||||
# (model/permission), and the extra resources fed to the step
|
||||
# (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"))
|
||||
|
||||
self.label_edit = QLineEdit()
|
||||
self.label_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("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)
|
||||
|
||||
# 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"))
|
||||
self.icon_edit.currentTextChanged.connect(self._on_edit)
|
||||
form.addRow(tr("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.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip"))
|
||||
self.gen_btn.setEnabled(ctx is not None)
|
||||
self.gen_btn.clicked.connect(self._ai_draft)
|
||||
instr_box = QWidget()
|
||||
ib = QVBoxLayout(instr_box)
|
||||
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)
|
||||
|
||||
# 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"))
|
||||
self.context_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_context"), self.context_edit)
|
||||
|
||||
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setEditable(True)
|
||||
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"))
|
||||
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)
|
||||
|
||||
self.perm_combo = QComboBox()
|
||||
for preset in PERMISSION_PRESETS:
|
||||
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
|
||||
self.perm_combo.currentIndexChanged.connect(self._on_edit)
|
||||
form2.addRow(tr("co4e.f_permission"), self.perm_combo)
|
||||
|
||||
verify_row = QHBoxLayout()
|
||||
self.verify_chk = QCheckBox(tr("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(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"))
|
||||
|
||||
# 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)
|
||||
|
||||
# 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.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.setIcon(icon("trash"))
|
||||
self.attach_del_btn.clicked.connect(self._del_attachment)
|
||||
att_btns = QHBoxLayout()
|
||||
att_btns.addWidget(self.attach_add_btn)
|
||||
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("", abtn)
|
||||
|
||||
# Parallel sub-agents get their OWN section — same header style as
|
||||
# Cơ bản/Model & Quyền/Skills & Tệp — rather than a row buried inside
|
||||
# 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"))
|
||||
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.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.setIcon(icon("trash"))
|
||||
self.sub_del_btn.clicked.connect(self._del_subagent)
|
||||
sub_btns = QHBoxLayout()
|
||||
sub_btns.addWidget(self.sub_add_btn)
|
||||
sub_btns.addWidget(self.sub_del_btn)
|
||||
sub_btns.addStretch(1)
|
||||
sbtn = QWidget(); sbtn.setLayout(sub_btns)
|
||||
form4.addRow(self.sub_list)
|
||||
form4.addRow("", sbtn)
|
||||
|
||||
# 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.setIcon(icon("play"))
|
||||
self.run_btn.setToolTip(tr("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.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"))
|
||||
self.del_btn.setFixedWidth(38)
|
||||
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
|
||||
foot = QHBoxLayout()
|
||||
foot.addWidget(self.run_btn, 1)
|
||||
foot.addWidget(self.run_from_btn, 1)
|
||||
foot.addWidget(self.del_btn)
|
||||
foot_w = QWidget(); foot_w.setLayout(foot)
|
||||
outer.addWidget(foot_w)
|
||||
# Without this, QVBoxLayout hands every child widget an EQUAL share of
|
||||
# whatever extra height the scroll area's viewport has beyond the
|
||||
# content's own sizeHint (setWidgetResizable(True) stretches `host` to
|
||||
# fill it) — each collapsed header's card was measuring a true
|
||||
# sizeHint of ~17px but rendering over 100px taller, and no amount of
|
||||
# margin/padding/spacing on the header itself could touch that: the
|
||||
# surplus was being spent on the cards, not around them. One trailing
|
||||
# stretch absorbs all of it instead, so every section (and the
|
||||
# footer) renders at exactly its own natural height.
|
||||
outer.addStretch(1)
|
||||
|
||||
self.setEnabled(False)
|
||||
|
||||
# ---- load a step ------------------------------------------------------
|
||||
def load_step(self, node_id: str, step: Step, skill_names: List[str]) -> None:
|
||||
self._loading = True
|
||||
self._node_id = node_id
|
||||
self._step = step
|
||||
self.setEnabled(True)
|
||||
self.label_edit.setText(step.label)
|
||||
self.role_edit.setText(step.role)
|
||||
self.icon_edit.setCurrentText(step.icon)
|
||||
self.instructions_edit.setPlainText(step.instructions)
|
||||
self.context_edit.setPlainText(getattr(step, "context", ""))
|
||||
self.model_combo.setEditText(step.model)
|
||||
idx = self.perm_combo.findData(step.permission_preset)
|
||||
self.perm_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.verify_chk.setChecked(step.self_verify)
|
||||
self.rounds_spin.setValue(max(1, step.max_verify_rounds))
|
||||
# skills checklist
|
||||
self.skills_list.clear()
|
||||
for name in skill_names:
|
||||
it = QListWidgetItem(name)
|
||||
it.setFlags(it.flags() | Qt.ItemIsUserCheckable)
|
||||
it.setCheckState(Qt.Checked if name in step.skills else Qt.Unchecked)
|
||||
self.skills_list.addItem(it)
|
||||
# attachments
|
||||
self.attach_list.clear()
|
||||
from pathlib import Path as _P
|
||||
for path in step.attachments:
|
||||
item = QListWidgetItem(_P(path).name)
|
||||
item.setToolTip(path)
|
||||
self.attach_list.addItem(item)
|
||||
# parallel sub-agents — the whole "Agent song song" section only
|
||||
# applies to parallel-variant steps, so the entire card (header
|
||||
# included) is hidden for any other step, not just its rows.
|
||||
is_par = step.is_parallel
|
||||
self._parallel_card.setVisible(is_par)
|
||||
self.sub_list.clear()
|
||||
if is_par:
|
||||
for sub in step.sub_agents:
|
||||
self.sub_list.addItem(sub.agent)
|
||||
self._loading = False
|
||||
|
||||
def clear_step(self) -> None:
|
||||
self._step = None
|
||||
self._node_id = ""
|
||||
self.setEnabled(False)
|
||||
|
||||
# ---- edits write back to the Step -------------------------------------
|
||||
def _on_edit(self, *_a) -> None:
|
||||
if self._loading or self._step is None:
|
||||
return
|
||||
s = self._step
|
||||
s.label = self.label_edit.text()
|
||||
s.role = self.role_edit.text().upper() or "AGENT"
|
||||
s.icon = self.icon_edit.currentText().strip()
|
||||
s.instructions = self.instructions_edit.toPlainText()
|
||||
s.context = self.context_edit.toPlainText()
|
||||
s.model = self.model_combo.currentText().strip()
|
||||
s.permission_preset = self.perm_combo.currentData() or "inherit"
|
||||
s.self_verify = self.verify_chk.isChecked()
|
||||
s.max_verify_rounds = self.rounds_spin.value()
|
||||
s.skills = [self.skills_list.item(i).text()
|
||||
for i in range(self.skills_list.count())
|
||||
if self.skills_list.item(i).checkState() == Qt.Checked]
|
||||
self.changed.emit()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""``_PaletteList`` — danh sách kéo-thả dùng chung của sidebar Co4E, tách khỏi
|
||||
``ui/co4e_tab.py``.
|
||||
|
||||
Vấn đề đang có: lớp này (nguyên bản ở ``ui/co4e_tab.py``) được 3 nơi dùng —
|
||||
``Co4ETab`` tự dùng cho ``wf_list`` (Workflows), còn
|
||||
``presentation/co4e/agent_list_panel.py``/``presentation/co4e/skills_list_panel.py``
|
||||
phải IMPORT NGƯỢC nó từ ``ui/co4e_tab.py`` bằng deferred-import bên trong
|
||||
``__init__`` (để né vòng lặp import: ``ui/co4e_tab.py`` import 2 panel đó ở
|
||||
đầu file, trong khi lớp ``_PaletteList`` lại định nghĩa Ở NGAY TRONG file đó).
|
||||
Hướng phụ thuộc "presentation -> ui" đó ngược với ý đồ của cả đợt tách này
|
||||
(``ui/co4e_tab.py`` đang co lại, ``presentation/co4e/`` là tầng con của nó, không
|
||||
phải ngược lại) — Lâm (N3) quyết 25/08: tách hẳn ``_PaletteList`` sang module
|
||||
RIÊNG, không thuộc ``ui/`` lẫn phụ thuộc vào ``ui/co4e_tab.py``, để 2 panel kia
|
||||
import thẳng ở top-level như bình thường, không cần deferred-import nữa.
|
||||
|
||||
Không đổi tên/hành vi — dời NGUYÊN VĂN. ``ui/co4e_tab.py`` giữ khả năng
|
||||
``from .co4e_tab import _PaletteList`` (qua re-export ở đầu file, giống khuôn
|
||||
đã dùng cho ``_skill_names``/``_ChatInput``) vì
|
||||
``tests/characterization/test_co4e_skills_panel.py`` import thẳng tên này từ
|
||||
``cowork_local.ui.co4e_tab``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PySide6.QtCore import QMimeData, Qt
|
||||
from PySide6.QtGui import QDrag
|
||||
from PySide6.QtWidgets import QListWidget
|
||||
|
||||
from .co4e_canvas_widget import CO4E_MIME
|
||||
|
||||
|
||||
class _PaletteList(QListWidget):
|
||||
"""A list whose rows can be dragged onto the canvas. Each item carries a
|
||||
JSON-able drag payload (a step dict, or a workflow dict) in ``payload_role``.
|
||||
Workflow rows also keep a (kind, id) tuple in Qt.UserRole for load/delete."""
|
||||
|
||||
def __init__(self, parent=None, payload_role=Qt.UserRole):
|
||||
super().__init__(parent)
|
||||
self._payload_role = payload_role
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QListWidget.DragOnly)
|
||||
|
||||
def startDrag(self, _actions): # noqa: N802
|
||||
item = self.currentItem()
|
||||
if item is None:
|
||||
return
|
||||
payload = item.data(self._payload_role)
|
||||
if not payload:
|
||||
return
|
||||
md = QMimeData()
|
||||
md.setData(CO4E_MIME, json.dumps(payload).encode("utf-8"))
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(md)
|
||||
drag.exec(Qt.CopyAction)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Panel khu vực SKILLS của sidebar Co4E — tách khỏi ``ui/co4e_tab.py``.
|
||||
|
||||
Vấn đề đang có: ``ui/co4e_tab.py`` đang gộp việc dựng widget (nút "Quản lý
|
||||
skill" + danh sách kéo-thả) ngay bên trong thân hàm dựng cả cột sidebar,
|
||||
khiến file đó (2000+ dòng) khó đọc và khó giữ dưới giới hạn CASAN (≤400 dòng
|
||||
mỗi file production). Đoạn dựng widget khu vực SKILLS (nguyên bản ở
|
||||
``ui/co4e_tab.py`` dòng 569-580) không phụ thuộc phần còn lại của
|
||||
``Co4ETab`` — nó chỉ tạo ``QPushButton`` + ``_PaletteList`` + layout bọc — nên
|
||||
tách được thành một ``QWidget`` con độc lập.
|
||||
|
||||
Cách làm: dời nguyên phần dựng widget sang đây, KHÔNG đổi tên thuộc tính so
|
||||
với bản gốc (``sk_manage_btn`` → ``manage_btn``, ``skill_list`` →
|
||||
``list_widget``, chỉ đổi TÊN THUỘC TÍNH cho khớp quy ước panel công khai, còn
|
||||
giá trị/thứ tự dựng/không dựng gì thêm thì giữ y hệt). Panel KHÔNG tự nối
|
||||
``.clicked`` của ``manage_btn`` và KHÔNG tự gọi ``_reload_sidebar`` — theo
|
||||
đúng nguyên tắc "một việc rẽ ra một lần": việc dựng widget (ở đây) tách khỏi
|
||||
việc nối hành vi (vẫn ở ``Co4ETab``, nơi biết ``_manage_skills`` là gì). Gộp
|
||||
hai việc đó vào panel sẽ buộc panel phải biết về ``Co4ETab``, xoá mất lý do
|
||||
tách nó ra.
|
||||
|
||||
Cập nhật 25/08: ``_PaletteList`` đã dời khỏi ``ui/co4e_tab.py`` sang
|
||||
``presentation/co4e/palette_list.py`` (module dùng chung, không phụ thuộc
|
||||
``ui/``) — import thẳng ở top-level như bên dưới, không còn cần deferred-import
|
||||
né vòng lặp nữa (xem docstring đầu ``presentation/co4e/palette_list.py`` để
|
||||
biết lý do dời).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from .palette_list import _PaletteList
|
||||
|
||||
|
||||
class SkillsListPanel(QWidget):
|
||||
"""Widget khu vực SKILLS của sidebar Co4E: nút quản lý + danh sách kéo-thả.
|
||||
|
||||
Vai trò: một widget con thuần ở tầng presentation, chỉ dựng cấu trúc UI
|
||||
(đúng những gì ``ui/co4e_tab.py`` dòng 569-580 làm trước đây), không biết
|
||||
gì về ``Co4ETab``/``_manage_skills``/``_reload_sidebar``. Bên gọi (hiện là
|
||||
``Co4ETab``) tự đọc ``.manage_btn``/``.list_widget`` để nối signal và nạp
|
||||
dữ liệu — panel không tự làm hộ, để giữ đúng ranh giới "một nơi một việc".
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.manage_btn = QPushButton(tr("co4e.manage_skills"))
|
||||
self.manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
|
||||
self.manage_btn.setObjectName("co4eSectionAction")
|
||||
self.manage_btn.setFlat(True)
|
||||
self.manage_btn.setCursor(Qt.PointingHandCursor)
|
||||
# KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao
|
||||
# xu ly - panel chi dung widget, khong biet _manage_skills la gi.
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
self.list_widget = _PaletteList()
|
||||
layout.addWidget(self.list_widget, 1)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Khung "section" gấp/mở (▶/▼) dùng chung cho các nhóm trường của
|
||||
``StepConfigPanel`` — tách khỏi ``ui/co4e_config_panel.py`` (nguyên bản dòng
|
||||
27-130).
|
||||
|
||||
Vấn đề đang có: ``StepConfigPanel`` (nay ở
|
||||
``presentation/co4e/node_property_panel.py``) có 4 nhóm trường (Cơ bản, Model
|
||||
& Quyền, Skills & Tệp, Agent song song), mỗi nhóm là một "card" gấp/mở độc
|
||||
lập với animation riêng. Phần dựng card này (``_SectionHeader`` +
|
||||
``_add_section``) không đọc/ghi bất kỳ trạng thái nào của ``StepConfigPanel``
|
||||
(không có ``self._step``, không có ``ctx``) — nó chỉ nhận ``outer``/``title``
|
||||
và trả về ``(form, card)`` để nơi gọi tự đổ các row vào — nên tách được thành
|
||||
module riêng, giống cách ``AgentListPanel``/``SkillsListPanel`` đã tách khỏi
|
||||
``ui/co4e_tab.py``. Giữ module riêng cũng là cách duy nhất để
|
||||
``node_property_panel.py`` (chứa phần còn lại của ``StepConfigPanel``) không
|
||||
vượt trần 400 dòng của CASAN Check 2.
|
||||
|
||||
Cách làm: dời NGUYÊN VĂN hằng số ``_SECTION_ANIM_MS``, class
|
||||
``_SectionHeader`` và hàm ``_add_section`` sang đây — không đổi tên, không
|
||||
đổi logic bên trong (kể cả các closure ``_on_finished``/``_toggle`` lồng
|
||||
trong ``_add_section``); chỉ đường import đổi cho khớp độ sâu package mới
|
||||
(``presentation/co4e/`` cách gốc ``cowork_local`` 3 cấp, thay vì 2 cấp như
|
||||
``ui/``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
|
||||
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from ...theme import current_palette
|
||||
|
||||
_SECTION_ANIM_MS = 180
|
||||
|
||||
|
||||
class _SectionHeader(QLabel):
|
||||
"""A clickable label — a QPushButton's own style chrome (border, native
|
||||
button margin, focus rect) always leaves a taller minimum height than a
|
||||
plain label, even once its QSS padding is zeroed out, so the header that
|
||||
needs to sit tight against its neighbours is a label, not a button."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802
|
||||
# fontMetrics() at construction time (before this label is ever part
|
||||
# of a shown top-level window) reflects the QSS font-size only if the
|
||||
# style has fully polished by then — on the very FIRST paint of the
|
||||
# Co4E screen it sometimes hasn't, so the fixed height computed in
|
||||
# _add_section is briefly wrong (too tall) until something else
|
||||
# triggers a relayout. Recomputing here, every time the label
|
||||
# actually becomes visible, means the first paint is never stale.
|
||||
self.setFixedHeight(self.fontMetrics().height())
|
||||
super().showEvent(event)
|
||||
|
||||
|
||||
def _add_section(outer: QVBoxLayout, title: 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
|
||||
border/background/box — the ▶/▼ marker and the heading text are the only
|
||||
things separating one group from the next; opening one never closes
|
||||
another (not an accordion, not a tab bar). Returns ``(form, card)``: add
|
||||
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."""
|
||||
p = current_palette()
|
||||
card = QWidget()
|
||||
card_lay = QVBoxLayout(card)
|
||||
card_lay.setContentsMargins(0, 0, 0, 0)
|
||||
card_lay.setSpacing(0)
|
||||
|
||||
header = _SectionHeader()
|
||||
header.setCursor(Qt.PointingHandCursor)
|
||||
header.setStyleSheet(f"font-weight:400; font-size:13px; color:{p.text}; padding:0; margin:0;")
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
# QSS font-size only lands on the widget's actual QFont (and therefore
|
||||
# its fontMetrics()) once the style sheet is polished — ensurePolished()
|
||||
# forces that now, so the fixed height below is computed from the 12px
|
||||
# font just set above, not the default one this label was constructed
|
||||
# with. A label's natural sizeHint still reserves font leading above/
|
||||
# below the glyphs on top of the (now zeroed) QSS padding — pinning the
|
||||
# height to the text's actual cap-to-baseline span is what closes that
|
||||
# last gap without clipping the ▶ glyph, the title, or Vietnamese
|
||||
# diacritics.
|
||||
header.ensurePolished()
|
||||
header.setFixedHeight(header.fontMetrics().height())
|
||||
header.setText(f"▶ {title}")
|
||||
card_lay.addWidget(header)
|
||||
|
||||
body = QWidget()
|
||||
body.setVisible(False)
|
||||
body.setMaximumHeight(0)
|
||||
form = QFormLayout(body)
|
||||
form.setContentsMargins(0, 6, 0, 0)
|
||||
card_lay.addWidget(body)
|
||||
|
||||
anim = QPropertyAnimation(body, b"maximumHeight", body)
|
||||
anim.setDuration(_SECTION_ANIM_MS)
|
||||
anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
is_open = False
|
||||
|
||||
def _on_finished() -> None:
|
||||
if is_open:
|
||||
# Uncapped once open, so switching to a step whose fields make
|
||||
# this section taller/shorter (e.g. a parallel node's sub-agent
|
||||
# list appearing) is never clipped by the height this animation
|
||||
# last landed on.
|
||||
body.setMaximumHeight(16_777_215)
|
||||
else:
|
||||
body.setVisible(False)
|
||||
anim.finished.connect(_on_finished)
|
||||
|
||||
def _toggle() -> None:
|
||||
nonlocal is_open
|
||||
is_open = not is_open
|
||||
header.setText(f"{'▼' if is_open else '▶'} {title}")
|
||||
anim.stop()
|
||||
if is_open:
|
||||
body.setVisible(True)
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(body.sizeHint().height())
|
||||
else:
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(0)
|
||||
anim.start()
|
||||
header.clicked.connect(_toggle)
|
||||
|
||||
outer.addWidget(card)
|
||||
return form, card
|
||||
Reference in New Issue
Block a user