feat(co4e): tách 6 widget UI khỏi ui/co4e_tab.py sang presentation/co4e/*

Lane N3 (Co4E Studio) — dùng bộ workflow refactor-god-file, mỗi bước có
characterization test trước khi tách, hậu kiểm ranh giới tầng sau mỗi bước:

- skills_list_panel.py / agent_list_panel.py — 2 khu vực sidebar
- co4e_canvas_widget.py + canvas_items.py + canvas_interaction_mixin.py —
  Co4ECanvas tách 3 file (vượt 400 dòng nếu đứng một mình)
- node_property_panel.py + node_property_actions_mixin.py +
  step_config_section.py — StepConfigPanel, cùng lý do
- co4e_run_control_widget.py — RunsPagePanel (trang Flow Status)
- co4e_chat_view.py — ChatPanel + _ChatInput + helper autocomplete
- palette_list.py — _PaletteList dời khỏi ui/co4e_tab.py, hết import ngược
  presentation -> ui (agent/skills panel giờ import top-level)

ui/co4e_tab.py giảm 2089 -> 1878 dòng, chỉ còn phần wiring + business logic
(Co4ERunManager/AgentWorker chưa đổi — nằm ngoài phạm vi này, xem docstring
presentation/co4e/co4e_tab.py). ui/co4e_canvas.py và ui/co4e_config_panel.py
còn lại là compat shim re-export, không đổi API cho bên gọi.

Thêm tests/test_co4e_integration.py — dựng thật Co4ETab qua build_co4e_tab(),
lái luồng qua nhiều panel trong cùng instance (thêm node, mở/gập chat, chuyển
trang Flow Status rồi quay lại không mất state canvas) — bắt lỗi wiring
xuyên-panel mà characterization test từng panel riêng không thấy được.

Đã xác minh: pytest 348 passed/1 skipped, tools/check_co4e.py sạch, không
file nào >400 dòng, domain/application không import PySide6, và so pixel
before/after (git worktree tại HEAD cũ) ra 0/1.125.000 pixel khác biệt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:43:42 +09:00
co-authored by Claude Sonnet 5
parent ca7ea1479d
commit 0631abf85f
45 changed files with 22739 additions and 1578 deletions
+87
View File
@@ -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)
+126
View File
@@ -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()
+284
View File
@@ -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()
+247
View File
@@ -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))
+258
View File
@@ -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)
+69
View File
@@ -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()
+293
View File
@@ -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()
+55
View File
@@ -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)
+59
View File
@@ -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)
+134
View File
@@ -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