Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+1
View File
@@ -0,0 +1 @@
"""Presentation Co4E package: Co4ECanvasWidget, NodePropertyPanel, RunControlWidget, Co4EChatView."""
+88
View File
@@ -0,0 +1,88 @@
"""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:
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
super().__init__(parent)
self.new_btn = QPushButton(tr("co4e.new"))
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)
+138
View File
@@ -0,0 +1,138 @@
"""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:
"""Khoảng cách Euclid giữa hai điểm."""
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
"""Điểm cách ``a`` một đoạn ``d`` theo hướng đi về ``b``.
Hai điểm trùng nhau thì trả về chính ``a``, tránh chia cho 0.
"""
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:
"""Đường gấp khúc này có cắt qua hình chữ nhật vật cản nào không.
Dùng để định tuyến đường nối lách qua các node khác.
"""
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:
"""Cắt chuỗi cho vừa ``n`` ký tự và thay xuống dòng bằng dấu cách — nhãn trên
khung vẽ chỉ có một dòng.
"""
text = (text or "").replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + "…"
@@ -0,0 +1,301 @@
"""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:
"""Gắn thanh điều khiển zoom/fit vào góc dưới-trái khung nhìn.
Widget được đặt làm con của ``viewport()`` chứ không của khung vẽ, nên nó
đứng yên khi người dùng kéo hay phóng to đồ thị.
"""
self._overlay = widget
widget.setParent(self.viewport())
widget.show()
widget.raise_()
self._place_overlay()
def _place_overlay(self) -> None:
"""Ghim lại thanh điều khiển vào góc dưới-trái, cách lề 12px."""
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
"""Đổi kích thước khung thì ghim lại thanh điều khiển."""
super().resizeEvent(e)
self._place_overlay()
def scrollContentsBy(self, dx, dy): # noqa: N802
"""Cuộn khung thì ghim lại thanh điều khiển.
``QGraphicsView`` cuộn cả widget con của viewport theo cảnh, nên không
ghim lại thì nút +/−/fit sẽ trôi khỏi góc.
"""
# 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
"""Lần hiện đầu tiên mới biết kích thước thật của viewport — ghim lại lúc đó."""
super().showEvent(e)
self._place_overlay() # viewport size is final once shown
# ---- zoom / fit -------------------------------------------------------
def _zoom_by(self, factor: float) -> None:
"""Phóng to/thu nhỏ theo hệ số, chặn trong khoảng ``_ZOOM_MIN``..``_ZOOM_MAX``.
Mức phóng hiện tại đọc thẳng từ ma trận biến đổi, KHÔNG dùng biến đếm
riêng: biến đếm lệch khỏi thực tế sau mỗi lần ``fit_view``/``relayout``/
``reset_zoom`` là đúng thứ từng làm nút +/− và Ctrl+lăn ngẫu nhiên chết.
Chặn ở đích rồi mới tính hệ số cần áp, nên vẫn phóng được sát mép giới hạn.
"""
# 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:
"""Phóng to một nấc (×1,15)."""
self._zoom_by(1.15)
def zoom_out(self) -> None:
"""Thu nhỏ một nấc (÷1,15)."""
self._zoom_by(1 / 1.15)
def reset_zoom(self) -> None:
"""Về đúng tỉ lệ 1:1 và bỏ mọi dịch chuyển."""
self.resetTransform()
self._zoom = 1.0
def wheelEvent(self, e):
"""Lăn chuột: Ctrl = phóng to/thu nhỏ quanh con trỏ, Shift = cuộn ngang,
không phím = cuộn dọc như mặc định.
"""
# 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):
"""Nhấn chuột giữa: bắt đầu kéo cả khung (pan), đổi con trỏ thành bàn tay nắm."""
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):
"""Đang kéo khung: dời hai thanh cuộn ngược chiều con trỏ."""
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):
"""Thả chuột giữa: kết thúc kéo khung, trả con trỏ về bình thường."""
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):
"""Phím tắt: Delete/Backspace xoá phần đang chọn, Esc huỷ lượt nối đang dở,
Ctrl +/−/0 phóng to/thu nhỏ/về 1:1.
"""
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):
"""Chỉ nhận thứ kéo vào mang đúng định dạng của bảng nguyên liệu Co4E."""
if e.mimeData().hasFormat(CO4E_MIME):
e.acceptProposedAction()
else:
super().dragEnterEvent(e)
def dragMoveEvent(self, e):
"""Giữ trạng thái nhận thả trong suốt lúc rê qua khung."""
if e.mimeData().hasFormat(CO4E_MIME):
e.acceptProposedAction()
else:
super().dragMoveEvent(e)
def dropEvent(self, e):
"""Thả một bước hoặc cả một luồng từ bảng nguyên liệu xuống đúng vị trí con trỏ.
Payload hỏng thì bỏ qua lặng lẽ — thả nhầm thứ gì đó vào khung không
được phép làm vỡ màn hình.
"""
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()
+340
View File
@@ -0,0 +1,340 @@
"""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"):
"""Thẻ một bước trên khung vẽ: kéo được, chọn được, và báo cho khung vẽ mỗi khi
nó đổi vị trí để đường nối vẽ lại theo.
"""
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:
"""Khung bao của node, nới rộng hai bên cho hai cổng vào/ra vẽ trọn vẹn."""
# 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:
"""Khung thẻ (đã trừ 1px viền) — phần thân thật sự được vẽ."""
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
def paint(self, p, _opt, _widget=None):
"""Vẽ thẻ bước: viền, dải tiêu đề, nhãn, vai trò, xem trước nội dung, chân thẻ và hai cổng.
Dải tiêu đề dùng màu trạng thái đã pha loãng (alpha 48) chứ không dùng
nguyên màu — để chữ trên thẻ vẫn là thứ nổi nhất. Cổng vào (trái) rỗng
ruột, cổng ra (phải) đặc ruột: cổng đặc chính là tay nắm để kéo nối sang
bước sau, nên nó phải trông "cầm được".
"""
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:
"""Điểm ``pos`` có nằm trong vùng bắt của cổng ra không.
Vùng bắt (``_PORT_HIT``) rộng hơn hình vẽ để chuột không cần trúng đúng
chấm tròn nhỏ mới kéo được.
"""
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):
"""Đồng bộ vị trí node về dữ liệu, và đưa node đang chọn lên trên.
Kéo node xong phải ghi lại toạ độ vào ``self.node`` rồi vẽ lại đường nối,
nếu không lần lưu kế tiếp sẽ ghi toạ độ cũ. Node được chọn nhảy lên
z=4 (trên cả cạnh ở z=3) để không bị đường nối che lúc đang sửa.
"""
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):
"""Đổi con trỏ thành bàn tay khi rê qua cổng ra — gợi ý là kéo nối được."""
# 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):
"""Bấm chuột: chốt lượt nối đang chờ, hoặc bắt đầu kéo nối từ cổng ra.
Thứ tự quan trọng: đang có lượt nối chờ (mở từ menu chuột phải) thì cú
bấm này là chọn đích, không phải chọn node.
"""
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):
"""Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ."""
if self._porting:
self.canvas.update_port_drag(self.mapToScene(e.pos()))
e.accept()
return
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
"""Thả chuột: kết thúc lượt kéo nối (nối cạnh nếu rơi trúng node khác)."""
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):
"""Bấm đúp một bước: báo lên để mở bảng thuộc tính của bước đó."""
self.canvas.node_activated.emit(self.node.id)
e.accept()
def contextMenuEvent(self, e):
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
menu = QMenu()
a_add = menu.addAction("+ Add next step")
a_conn = menu.addAction("→ Connect from here")
a_del = menu.addAction("🗑 Delete step")
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:
"""Toạ độ tâm node trên khung — dùng để định tuyến đường nối."""
return self.pos() + QPointF(_NODE_W / 2, _NODE_H / 2)
class _EdgeItem(QGraphicsPathItem):
"""Một đường nối giữa hai bước, kèm mũi tên ở đầu đích.
Nằm ở z=3, tức trên thẻ bước (z=2), để đường nối không bao giờ bị thẻ
che khuất — node đang được chọn thì tự nhảy lên z=4.
"""
def __init__(self, edge: Edge, canvas: "Co4ECanvas"):
"""Đường nối giữa hai bước.
Đặt ``z=3``, trên thẻ bước (``z=2``): đường nối bị thẻ che thì không nhìn ra
luồng chạy nữa.
"""
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):
"""Chọn màu và độ dày nét theo trạng thái: đang chọn > đang rê chuột > bình thường."""
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):
"""Vẽ lại đường theo danh sách điểm đã định tuyến, nhớ lại điểm cuối để đặt mũi tên."""
self._dst = points[-1] if points else None
self.setPath(_rounded_path(points))
def boundingRect(self):
"""Khung bao của đường, nới thêm 10px mỗi phía cho mũi tên."""
return super().boundingRect().adjusted(-10, -10, 10, 10) # room for the arrowhead
def shape(self):
"""Nới vùng bấm/chọn lên 14px.
Đường nối chỉ dày 2px — không nới thì gần như không thể trỏ trúng để
chọn hay mở menu xoá.
"""
# 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):
"""Rê chuột vào: làm nét đậm lên để thấy rõ đang trỏ vào đường nào."""
self._hover = True
self._apply_pen()
self.update()
super().hoverEnterEvent(e)
def hoverLeaveEvent(self, e):
"""Rời chuột: trả nét về trạng thái bình thường."""
self._hover = False
self._apply_pen()
self.update()
super().hoverLeaveEvent(e)
def paint(self, p, opt, widget=None):
"""Vẽ đường nối và mũi tên tam giác chĩa vào cổng vào của node đích."""
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 chuột phải trên đường nối: xoá liên kết."""
menu = QMenu()
act_del = menu.addAction("🗑 Delete connection")
if menu.exec(e.screenPos()) is act_del:
self.canvas.delete_edge(self.edge)
e.accept()
+61
View File
@@ -0,0 +1,61 @@
"""Agent và skill dùng trong flow — R08-T09.
"""
from __future__ import annotations
import re
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from ...core import co4e, skills as skills_mod
from ...i18n import tr
from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EAgentsMixin:
"""Quản lý agent tự tạo và thư viện skill dùng trong luồng Co4E."""
def _new_agent(self) -> None:
"""Mở hộp thoại tạo agent tự tạo mới."""
self._edit_agent_dialog(co4e.new_custom_agent(""))
def _edit_agent(self) -> None:
"""Sửa agent đang chọn; chưa chọn gì thì nhắc người dùng chọn."""
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
agent = next((a for a in co4e.list_custom_agents() if a.id == cid), None)
if agent is not None:
self._edit_agent_dialog(agent)
def _edit_agent_dialog(self, agent: co4e.CustomAgent) -> None:
"""Mở hộp thoại sửa agent; bấm OK thì lưu và nạp lại cột bên trái."""
from ...ui.co4e_agent_dialog import Co4EAgentDialog
dlg = Co4EAgentDialog(self.ctx, agent, _skill_names(), self)
if dlg.exec():
co4e.save_custom_agent(dlg.result_agent())
self._reload_sidebar()
def _delete_agent(self) -> None:
"""Xoá agent đang chọn; chưa chọn gì thì nhắc người dùng chọn."""
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
co4e.delete_custom_agent(cid)
self._reload_sidebar()
def _manage_skills(self) -> None:
"""Mở trình quản lý Skill, đóng xong thì nạp lại cột bên trái."""
from ...ui.skills_dialog import SkillsDialog
SkillsDialog(self, self.ctx).exec()
self._reload_sidebar()
def _skill_map(self) -> Dict[str, str]:
"""Bảng ``{tên skill: nội dung chỉ dẫn}`` để truyền cho một lượt chạy luồng.
Cắt bỏ dòng tiêu đề của khối chỉ dẫn, chỉ giữ phần nội dung.
"""
out = {}
for name in _skill_names():
block = skills_mod.skill_prefix_for(name)
if block:
out[name] = block.split("\n", 1)[1] if "\n" in block else block
return out
+314
View File
@@ -0,0 +1,314 @@
"""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):
"""Khung vẽ luồng Co4E: node là bước, cạnh là thứ tự chạy.
Lớp này giữ *dữ liệu đồ thị* (thêm/xoá node, nối cạnh, định tuyến đường
nối). Phần thao tác chuột/bàn phím — phóng to, kéo màn, kéo-thả từ bảng
nguyên liệu — nằm ở ``_CanvasInteractionMixin`` để file này không vượt
hạn mức 400 dòng.
Mọi thay đổi làm đồ thị khác đi đều phát ``graph_changed`` để lớp trên
tự lưu.
"""
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):
"""Khung vẽ workflow: kéo chọn theo vùng, thu phóng lấy con trỏ làm tâm."""
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:
"""Nạp lại toàn bộ đồ thị, xoá sạch khung cũ.
Cạnh trỏ tới node không tồn tại thì bỏ lặng lẽ — file luồng sửa tay
hoặc luồng cũ có thể còn cạnh mồ côi, và treo cả màn vì một cạnh hỏng
thì tệ hơn là bỏ nó đi.
"""
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):
"""Danh sách node (dạng dữ liệu, không phải item đồ hoạ) để đem đi lưu."""
return [it.node for it in self._nodes.values()]
def edges(self):
"""Danh sách cạnh (dạng dữ liệu) để đem đi lưu."""
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:
"""Thêm một bước vào khung và trả về id node vừa tạo.
``connect_from`` trỏ tới node nào thì nối luôn một cạnh từ đó sang; id
không tồn tại thì bỏ qua phần nối. Node mới luôn được chọn ngay để bảng
thuộc tính bên phải mở đúng bước vừa thêm.
"""
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:
"""Thả một bước từ bảng nguyên liệu xuống đúng vị trí con trỏ.
Tự nối vào đuôi chuỗi hiện có, để kéo liên tiếp vài bước là thành một
luồng chạy được mà không phải nối tay từng cạnh.
"""
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:
"""Bắt đầu nối cạnh bằng menu chuột phải: ghi nhớ node nguồn."""
self._connect_from = source_id
def _finish_connect(self, target_id: str) -> None:
"""Kết thúc lượt nối bằng menu: tạo cạnh và xoá trạng thái đang nối.
Tự nối vào chính mình thì không tạo cạnh, nhưng vẫn xoá trạng thái —
nếu không, lần bấm kế tiếp sẽ nối nhầm từ node cũ.
"""
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:
"""Bắt đầu kéo cạnh từ cổng ra của một node: dựng đường nét đứt tạm."""
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:
"""Vẽ lại đường nét đứt theo con trỏ trong lúc kéo."""
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:
"""Thả chuột: nối cạnh nếu rơi trúng một node khác, và luôn dọn đường tạm.
Thả vào chỗ trống hay vào chính node nguồn đều không tạo cạnh — nhưng
trạng thái kéo vẫn phải được xoá, nếu không đường nét đứt sẽ dính lại.
"""
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]:
"""Id node nằm dưới một điểm trên khung; ``None`` nếu là chỗ trống."""
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:
"""Tạo cạnh nguồn → đích.
Bỏ qua cạnh tự nối và cạnh trùng cặp đã có — kéo hai lần cùng một
hướng không được sinh ra hai đường chồng lên nhau.
"""
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:
"""Đưa một cạnh vào khung vẽ và vào danh sách quản lý."""
item = _EdgeItem(edge, self)
self._edges.append(item)
self._scene.addItem(item)
def delete_edge(self, edge: Edge) -> None:
"""Xoá cạnh, so khớp theo cặp nguồn/đích chứ không chỉ theo danh tính đối
tượng — cạnh có thể đã được dựng lại sau một lần nạp.
"""
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:
"""Xoá một node và mọi cạnh dính vào nó. Id không tồn tại thì bỏ qua."""
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:
"""Xoá mọi node và cạnh đang được chọn.
Xoá node trước: node kéo theo cạnh của nó, nên vòng lặp cạnh phía sau
chỉ còn phải xử lý các cạnh được chọn riêng lẻ.
"""
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:
"""Đổi màu trạng thái của một node lúc luồng đang chạy (đang chạy/xong/lỗi)."""
item = self._nodes.get(node_id)
if item is not None:
item.status = status
item.update()
def reset_statuses(self) -> None:
"""Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng."""
for it in self._nodes.values():
it.status = "idle"
it.update()
def refresh_node(self, node_id: str) -> None:
"""Vẽ lại một node sau khi nội dung bước của nó bị sửa."""
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:
"""Định tuyến lại mọi đường nối.
Cạnh luôn đi từ cổng ra (giữa cạnh phải) sang cổng vào (giữa cạnh trái)
và lách qua các node khác, nên luồng đọc được từ trái sang phải.
"""
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))
+371
View File
@@ -0,0 +1,371 @@
"""Khung chat của Co4E và việc đếm token — R08-T09.
Khác chat của Cowork ở một điểm: ở đây câu người dùng gõ có thể mang chỉ thị
chọn agent (``_extract_agent_directive``), và mỗi lượt được định tuyến riêng
theo cấu hình routing của Co4E.
"""
from __future__ import annotations
import re
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QSplitter, QWidget
from ...core import co4e, skills as skills_mod
from ...core.co4e_builtins import BUILTIN_AGENTS
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.chat_view import ChatView
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import ChatPanel
class Co4EChatMixin:
"""Khung chat của Co4E Studio: mỗi luồng có nhật ký hội thoại RIÊNG.
Hai luồng chạy song song không được trộn tin nhắn vào nhau, nên mọi thứ ở
đây đều lấy nhật ký qua :meth:`_active_log` thay vì dùng một biến chung.
"""
def _build_chat(self) -> QWidget:
"""Widget construction lives in ``ChatPanel`` (presentation/co4e/
co4e_chat_view.py); this method just wires the panel's public
attributes to the handler methods that know about ``self``
(``_toggle_messages``, ``_chat_send``) and keeps the state that is
NOT part of the panel's own construction (``_flow_logs`` — per-flow
ChatView dict, ``_co4e_routed_provider`` — routing override, and
``_vsplit_sizes``/``_msgs_collapsed`` — used by ``_toggle_messages``
below to restore/collapse the splitter) — the panel itself stays
ignorant of ``Co4ETab``.
"""
panel = ChatPanel(self.ctx)
self._chat_widget = panel
self.msgs_icon = panel.msgs_icon
self.msgs_title = panel.msgs_title
self.chat_toggle_btn = panel.chat_toggle_btn
self.chat_toggle_btn.clicked.connect(self._toggle_messages)
self._mhdr = panel.header
self.chat_stack = panel.chat_stack
self._flow_logs: Dict[str, ChatView] = {}
self.chat_input_row = panel.chat_input_row
self._usage_total_lbl = panel.usage_total_lbl
self.chat_input = panel.chat_input
self.chat_input.submit.connect(self._chat_send)
self.chat_send_btn = panel.chat_send_btn
self.chat_send_btn.clicked.connect(self._chat_send)
self.co4e_routing_toggle = panel.co4e_routing_toggle
self._co4e_routed_provider = None # routing provider override for the next turn
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
self._msgs_collapsed = True
return panel
def _toggle_messages(self) -> None:
"""Show/hide the WHOLE chat box (message list + composer) below the
header. Collapsing hands the freed height to the canvas.
A QSplitter's ``setMaximumHeight`` on one side does NOT automatically
redistribute the freed space to the other side — it just shrinks the
splitter's own total height, leaving the canvas frozen at its old size
and blank space below it. So this explicitly calls ``setSizes`` on both
the collapse AND the expand path, computed from the splitter's CURRENT
total (not a hardcoded guess) — that total stays constant; only how
it's split between canvas/chat changes."""
self._msgs_collapsed = not self._msgs_collapsed
collapsed_h = self._mhdr.sizeHint().height() + 6
if self._msgs_collapsed:
if hasattr(self, "_vsplit"):
self._vsplit_sizes = self._vsplit.sizes() # remember to restore
self.chat_stack.hide()
self.chat_input_row.hide()
self._chat_widget.setMaximumHeight(collapsed_h)
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → click to expand
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
if hasattr(self, "_vsplit"):
total = sum(self._vsplit.sizes()) or (self._vsplit_sizes and sum(self._vsplit_sizes)) or 760
self._vsplit.setSizes([max(0, total - collapsed_h), collapsed_h])
else:
self._chat_widget.setMaximumHeight(16777215)
self.chat_stack.show()
self.chat_input_row.show()
self.chat_toggle_btn.setIcon(icon("chevron-down")) # expanded → click to collapse
self.chat_toggle_btn.setToolTip(tr("co4e.tt_collapse_msgs"))
if getattr(self, "_vsplit_sizes", None) and hasattr(self, "_vsplit"):
self._vsplit.setSizes(self._vsplit_sizes)
return
def _ensure_flow_log(self, wf_id: str) -> ChatView:
"""The ChatView for a flow, created + added to the stack on first use so
each flow tab keeps a SEPARATE conversation."""
log = self._flow_logs.get(wf_id)
if log is None:
log = ChatView()
log._co4e_plan_bubble = None # per-flow 'current plan' bubble
self._flow_logs[wf_id] = log
self.chat_stack.addWidget(log)
return log
def _active_log(self) -> ChatView:
"""Nhật ký hội thoại của luồng đang mở, tạo lười nếu chưa có."""
wf = getattr(self, "_wf", None)
return self._ensure_flow_log(wf.id if wf is not None else "__none__")
@property
def chat_log(self) -> ChatView:
"""The conversation of the CURRENTLY-shown flow (all append/stream calls
go here). Assignment is not supported — logs are per-flow now."""
return self._active_log()
@property
def _plan_bubble(self):
"""Bong bóng kế hoạch của luồng ĐANG mở; ``None`` nếu lượt này chưa có kế hoạch.
Cất trên chính nhật ký của luồng chứ không trên mixin, để hai luồng chạy song
song không ghi đè kế hoạch của nhau.
"""
return getattr(self._active_log(), "_co4e_plan_bubble", None)
@_plan_bubble.setter
def _plan_bubble(self, value) -> None:
"""Gắn bong bóng kế hoạch vào nhật ký của luồng đang mở."""
self._active_log()._co4e_plan_bubble = value
def _chat_send(self) -> None:
"""Gửi tin nhắn trong khung chat Co4E; đang chạy dở thì bỏ qua."""
text = self.chat_input.text().strip()
if not text or self._chat_worker is not None:
return
self.chat_input.clear()
self._append_chat("user", text)
skill_prefix, request, info = skills_mod.parse_skill_command(text)
if info is not None:
self._append_chat("system", info)
return
system_parts = []
if skill_prefix:
system_parts.append(skill_prefix)
agent_name, request = self._extract_agent_directive(request)
model = ""
if agent_name:
persona = self._resolve_agent(agent_name)
if persona is None:
self._append_chat("system", tr("co4e.agent_not_found", name=agent_name))
return
system_parts.append(persona[0])
model = persona[1]
# Auto Model Routing — only when the user hasn't pinned an agent's own
# model (an explicit pin wins). May switch provider+model for this turn.
if not model:
model = self._apply_co4e_routing(request)
self._run_chat_turn(system_parts, request, model)
def _apply_co4e_routing(self, request: str) -> str:
"""Route this Co4E turn to the best-fit model. Returns the model id to
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
a cross-provider switch is chosen.
R03-T05: the Off/Auto/Manual/Fallback rules are no longer re-implemented
here — they come from the shared ``RoutingApplicationService``, so Co4E,
the Cowork chat and AI-Edit can never drift apart again. This method only
adapts between Co4E's state and the service's DTOs. Never raises — falls
back to the default model on any error.
"""
self._co4e_routed_provider = None
try:
from ...application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from ...ui.routing_toggle import confirm_switch
cur_provider = self.ctx.config.active_provider
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="co4e",
prompt=request,
current_provider=cur_provider,
current_model=cur_model,
),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return "" # '' keeps the provider's configured default model
# Remembered so the worker's build_provider_for() can follow a
# cross-provider switch, not just a model change.
self._co4e_routed_provider = outcome.provider
self._append_chat("system", tr(
"routing.switched_notice",
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
return outcome.model
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
self._co4e_routed_provider = None
return ""
def _extract_agent_directive(self, text: str):
"""Tách lệnh ``/agent:<tên>`` khỏi câu người dùng gõ.
Trả về (tên agent, phần câu còn lại).
"""
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
if not m:
return "", text
name = m.group(1)
rest = (text[:m.start()] + " " + text[m.end():]).strip()
return name, rest
def _resolve_agent(self, name: str):
"""Tìm agent theo slug hoặc tên, ưu tiên agent dựng sẵn rồi tới agent tự tạo."""
low = name.lower()
for a in BUILTIN_AGENTS:
if a.slug == low or a.name.lower() == low:
return (f"You are the {a.role} agent — {a.name}.\n{a.instructions}", "")
for ca in co4e.list_custom_agents():
if co4e.slugify(ca.name) == low or ca.name.lower() == low:
return (f"You are the {ca.role} agent — {ca.name}.\n{ca.instructions}", ca.model)
return None
def _run_chat_turn(self, system_parts: List[str], request: str, model: str) -> None:
"""Chạy một lượt chat ở luồng nền, ghi kết quả vào nhật ký của ĐÚNG luồng đã
phát lệnh — bắt giữ tham chiếu nhật ký ngay từ đầu, vì người dùng có thể
chuyển sang luồng khác trong lúc chờ.
"""
self.chat_send_btn.setEnabled(False)
log = self.chat_log # THIS flow's conversation (captured)
log._co4e_plan_bubble = None # a fresh plan for this turn
ctx = self.ctx
out_dir = self._out_dir()
sys_text = "\n\n".join(p for p in system_parts if p)
prompt = f"{sys_text}\n\n{request}" if sys_text else request
assistant = log.add_assistant() # stream into this live bubble
state = {"text": ""}
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
flow_label = wf.name if wf is not None else "flow"
def job(worker: AgentWorker):
"""Chạy nền: gọi agent Cowork và ghi nhận token đã dùng."""
from ...core import agent_roles, usage_tracker as ut
from ...core.chat_agent import run_cowork
from ...core.co4e_runner import _usage_delta
# An Auto/Manual routing switch may target a different provider.
provider = ctx.build_provider_for(getattr(self, "_co4e_routed_provider", None), model or None)
messages = [{"role": "user", "content": prompt}]
ut.set_context("co4e", flow_label) # attribute + measure this turn's usage
ut.begin_accumulation()
base = ut.accumulated()
def _emit(ev):
"""Chuyển tiếp sự kiện từ agent, lọc bỏ thứ khung chat này không dùng."""
if not isinstance(ev, dict):
return
t = ev.get("type")
if t == "text":
worker.emit_event({"type": "text", "delta": ev.get("delta", "")})
elif t == "plan_set":
worker.emit_event({"type": "plan_set", "steps": ev.get("steps") or []})
try:
run_cowork(provider, messages, out_dir, _emit, worker.is_cancelled,
security_config=ctx.config, agent_role=agent_roles.COWORK,
run_to_completion=True, enforce_rules=False)
usage = _usage_delta(base, ctx.config)
finally:
ut.end_accumulation()
for m in reversed(messages):
if m.get("role") == "assistant" and m.get("content"):
return {"text": str(m["content"]), "usage": usage}
return {"text": "", "usage": usage}
def on_event(ev):
"""Vẽ dần từng mẩu trả lời vào bong bóng của lượt này."""
if ev.get("type") == "text":
state["text"] += ev.get("delta", "")
assistant.set_markdown(state["text"])
log.scroll_to_bottom()
elif ev.get("type") == "plan_set":
self._append_plan(ev.get("steps") or [], log=log)
def done(result: dict):
"""Lượt xong: chốt nội dung cuối và mở khoá nút gửi."""
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
final = result.get("text") or state["text"]
assistant.set_markdown(final or "(no output)")
self._apply_usage(assistant, wf_id, result.get("usage"))
log.scroll_to_bottom()
def failed(err: str):
"""Lượt lỗi: hiện lỗi ngay trong nhật ký và mở khoá nút gửi."""
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
self._append_chat("error", f"[error: {err}]", log=log)
w = AgentWorker(job)
w.event.connect(on_event)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._chat_worker = w
w.start()
def _append_chat(self, role: str, text: str, log: "ChatView" = None) -> None:
"""Add one message bubble to a flow's conversation. ``log`` defaults to the
active flow's log; a run/stream passes its OWN captured log so events land
in the right flow even if the user switches tabs mid-run."""
log = log or self.chat_log
if role == "user":
bub = log.add_user(text)
elif role == "assistant":
bub = log.add_assistant()
bub.set_markdown(text)
elif role == "error":
bub = log.add_error(text)
else: # system status marker
bub = log.add_status(text)
log.scroll_to_bottom()
return bub
def _fmt_usage(self, d_in: int, d_out: int, d_cache: int, cost_usd: float) -> str:
"""The Cowork-style footer string: ↓in ↑out ▤(in+out+cache) $cost, priced
with the Monitoring model-price table in the app's display currency."""
from ...core import model_pricing as mp, usage_tracker as ut
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
return (f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
f"{ut.format_cost(cost_usd, pricing)}")
def _apply_usage(self, bub, wf_id, usage) -> None:
"""Attach a token/cost footer to a step's bubble and add it to the flow's
running total (mirrors Cowork's per-message + conversation-total display)."""
if not isinstance(usage, dict):
return
d_in = int(usage.get("in", 0) or 0)
d_out = int(usage.get("out", 0) or 0)
d_cache = int(usage.get("cache", 0) or 0)
cost = float(usage.get("cost_usd", 0.0) or 0.0)
if bub is not None and (d_in or d_out):
try:
bub.add_usage(self._fmt_usage(d_in, d_out, d_cache, cost))
except Exception: # noqa: BLE001 - a usage footer must never break the run
pass
if wf_id is not None:
tot = self._flow_usage.setdefault(wf_id, {"in": 0, "out": 0, "cache": 0, "cost": 0.0})
tot["in"] += d_in; tot["out"] += d_out; tot["cache"] += d_cache; tot["cost"] += cost
self._refresh_usage_total(wf_id)
def _refresh_usage_total(self, only_wf: str = None) -> None:
"""Update the bottom conversation total to the CURRENT flow's running
usage (skip if the event is for a different, background flow)."""
lbl = getattr(self, "_usage_total_lbl", None)
if lbl is None:
return
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
if only_wf is not None and only_wf != wf_id:
return
tot = self._flow_usage.get(wf_id) if wf_id else None
if not tot or not (tot["in"] or tot["out"]):
lbl.setText("")
return
lbl.setText(self._fmt_usage(int(tot["in"]), int(tot["out"]),
int(tot["cache"]), float(tot["cost"])))
def _append_diff(self, title: str, diff: str, log: "ChatView" = None) -> None:
"""Render a before/after diff as a collapsible colored diff bubble."""
log = log or self.chat_log
log.add_diff(f"▤ {title}", diff)
log.scroll_to_bottom()
def _append_plan(self, steps, log: "ChatView" = None) -> None:
"""Show the plan INLINE in the conversation as an expandable block; update
the same (per-flow) bubble in place so steps tick off (✓) as they complete."""
log = log or self.chat_log
from ...ui.co4e_tab import _fmt_plan
body = _fmt_plan(steps)
if not body:
return
if getattr(log, "_co4e_plan_bubble", None) is None:
log._co4e_plan_bubble = log.add_plan(body)
else:
log._co4e_plan_bubble.set_plain(body)
log.scroll_to_bottom()
+273
View File
@@ -0,0 +1,273 @@
"""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]:
"""Tên mọi skill dùng được (tự tạo + dựng sẵn); lỗi đọc thì trả list rỗng."""
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]:
"""Tên mọi agent dùng được: agent tự tạo trước, rồi tới agent dựng sẵn chưa trùng tên."""
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):
"""Ô soạn của khung chat Co4E, kèm danh sách gợi ý nổi lên khi gõ lệnh."""
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:
"""Mở bảng gợi ý khi con trỏ đang đứng trên một lệnh ``/skill`` hoặc ``/agent``."""
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:
"""Thêm một dòng vào bảng gợi ý, kèm chuỗi sẽ chèn và tooltip mô tả."""
it = QListWidgetItem(label)
it.setData(Qt.UserRole, replacement)
it.setToolTip(tip)
self._popup.addItem(it)
def _accept(self) -> None:
"""Chọn một mục: chèn lệnh tương ứng vào ô nhập và đóng bảng gợi ý."""
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
"""Bấm ra chỗ khác thì ẩn bảng gợi ý — trừ khi chuột đang ở trên chính bảng."""
if not self._popup.underMouse():
self._popup.hide()
super().focusOutEvent(e)
def keyPressEvent(self, e): # noqa: N802
"""Bảng gợi ý đang mở thì lên/xuống chọn mục, Enter nhận, Esc đóng;
không thì Enter gửi tin nhắn như bình thường.
"""
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:
"""Khung chat của Co4E Studio.
Tiêu đề "Tin nhắn" nằm TRÊN hộp chat và đóng vai công tắc: bấm vào là ẩn/hiện
cả khối bên dưới, để lấy chỗ cho khung vẽ.
"""
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)
+194
View File
@@ -0,0 +1,194 @@
"""Dải tab các flow đang mở — R08-T09.
Mỗi flow người dùng mở là một tab. Đóng tab không đóng flow: nó chỉ rời khỏi
dải, flow vẫn còn trong thư viện bên trái.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import QPushButton, QTabBar
from ...core import co4e
from ...i18n import tr
from ...ui.icons import icon
class Co4EFlowTabsMixin:
"""Dải tab kiểu trình duyệt của Co4E Studio: mỗi luồng đang mở là một tab.
Tab chỉ số 0 luôn là trang Flow Status được ghim, nên luồng thứ ``i`` nằm ở
tab ``i + 1`` — mọi phép đổi chỉ số trong file này đều theo quy ước đó.
"""
def _open_flow(self, wf: co4e.Workflow) -> None:
"""Open ``wf`` in a tab — reuse its tab if already open (like a browser),
else add a new one and switch to it. Bar index 0 is the pinned Runs tab,
so flow ``i`` lives at bar index ``i + 1``. If the flow has an active run,
its live status is reflected on the canvas."""
for i, f in enumerate(self._flows):
if f.id == wf.id:
self._flows[i] = wf
bar_idx = i + 1
self.flow_bar.setTabText(bar_idx, wf.name or tr("co4e.untitled"))
if self.flow_bar.currentIndex() == bar_idx:
self._active_flow_idx = -1 # force reload of same tab
self._on_flow_tab_changed(bar_idx)
else:
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
return
# Without the strip there is nowhere to switch between open flows, so
# opening one REPLACES the one on the canvas (saved first, as the tab
# switch used to do). Runs already in progress are unaffected — they are
# tracked per flow id and keep going in the background.
self._close_other_flows()
self._flows.append(wf)
self.flow_bar.blockSignals(True)
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
self._add_tab_close_button(bar_idx)
self.flow_bar.blockSignals(False)
if self.flow_bar.currentIndex() == bar_idx:
self._on_flow_tab_changed(bar_idx) # already current → load manually
else:
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
def _close_other_flows(self) -> None:
"""Leave the canvas empty of flows, saving whatever was on it.
Called before opening a flow, because the tab strip that used to hold
several at once is gone. Tab 0 (Runs) is never touched.
"""
if not self._flows:
return
if 0 <= self._active_flow_idx < len(self._flows):
self._sync_wf_from_canvas()
self.flow_bar.blockSignals(True)
for idx in range(self.flow_bar.count() - 1, 0, -1):
self.flow_bar.removeTab(idx)
self.flow_bar.blockSignals(False)
self._flows.clear()
self._active_flow_idx = -1
def _show_runs(self, on: bool) -> None:
"""Swap the centre between the flow editor and the Runs table.
This is where the pinned "Runs" tab went when the strip was removed —
same page, same table, reached from a toggle in the flow toolbar.
"""
target = 0 if on else min(1, self.flow_bar.count() - 1)
if self.flow_bar.currentIndex() == target:
self._on_flow_tab_changed(target) # already there → re-apply
else:
self.flow_bar.setCurrentIndex(target)
def _on_flow_tab_changed(self, idx: int) -> None:
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
"""Đổi tab: lưu luồng vừa rời đi rồi nạp luồng mới lên khung vẽ.
Phải lưu TRƯỚC khi chuyển, nếu không thay đổi chưa lưu của luồng cũ sẽ bị
khung vẽ ghi đè khi nạp luồng mới.
"""
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
self._sync_wf_from_canvas()
if idx <= 0: # the Runs page
self._active_flow_idx = -1
self.center_stack.setCurrentIndex(0)
self._sync_runs_toggle(True)
self._refresh_runs()
return
flow_idx = idx - 1
if not (0 <= flow_idx < len(self._flows)):
return
self._active_flow_idx = flow_idx
self.center_stack.setCurrentIndex(1)
self._sync_runs_toggle(False)
self._apply_workflow(self._flows[flow_idx])
def _sync_runs_toggle(self, on: bool) -> None:
"""Keep the Runs toggle showing which page is up, however it got there
(a double-click in the runs table also switches pages)."""
btn = getattr(self, "runs_btn", None)
if btn is not None and btn.isChecked() != on:
blocked = btn.blockSignals(True)
btn.setChecked(on)
btn.blockSignals(blocked)
def _add_tab_close_button(self, idx: int) -> None:
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
the tab's right side, vertically centered and INSIDE the tab (reliable
across themes, unlike the CSS-positioned default which looked detached)."""
btn = QPushButton("×") # ×
btn.setObjectName("flowTabClose")
btn.setFlat(True)
btn.setFixedSize(16, 16)
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda: self._close_flow_tab_button(btn))
self.flow_bar.setTabButton(idx, QTabBar.RightSide, btn)
def _close_flow_tab_button(self, btn) -> None:
"""Tìm tab ứng với nút ✕ vừa bấm rồi đóng tab đó."""
for i in range(self.flow_bar.count()):
if self.flow_bar.tabButton(i, QTabBar.RightSide) is btn:
self._close_flow_tab(i)
return
def _close_flow_tab(self, idx: int) -> None:
"""Đóng một tab luồng. Tab Flow Status (chỉ số 0) được ghim, không đóng được."""
if idx <= 0: # Runs tab is pinned
return
flow_idx = idx - 1
if not (0 <= flow_idx < len(self._flows)):
return
closing = self._flows[flow_idx]
# Stop mirroring the closed flow's run onto the canvas — the run itself
# keeps going in the background and stays in Flow Status. (Per-flow run
# tracking: only this flow's entry is dropped; other flows keep running.)
rid = self._flow_runs.pop(closing.id, None)
if rid is not None:
self._run_logs.pop(rid, None)
if getattr(self, "_wf", None) is not None and self._wf.id == closing.id:
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._flows.pop(flow_idx)
self.flow_bar.blockSignals(True)
self.flow_bar.removeTab(idx)
self.flow_bar.blockSignals(False)
self._active_flow_idx = -1
if not self._flows:
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
else:
new_bar = min(idx, len(self._flows)) # clamp to the last flow tab
self.flow_bar.blockSignals(True)
self.flow_bar.setCurrentIndex(new_bar)
self.flow_bar.blockSignals(False)
self._on_flow_tab_changed(new_bar)
def _sync_active_flow_tab_text(self) -> None:
"""Cập nhật nhãn tab đang mở theo tên luồng; không bao giờ đổi tên tab Flow Status."""
i = self.flow_bar.currentIndex()
if i >= 1: # never rename the Runs tab
self.flow_bar.setTabText(i, self._wf.name or tr("co4e.untitled"))
def _reflect_active_run(self, wf_id: str) -> None:
"""If a run for this flow is active, mirror its live node statuses onto the
canvas and keep tracking it so updates continue to show."""
for h in self.manager.all_runs():
if h.wf_id == wf_id and h.running:
self._flow_runs[wf_id] = h.id
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
return
def _cur_run_id(self) -> Optional[str]:
"""The active canvas run of the CURRENTLY-shown flow, or None. Clears a
stale entry if that run already finished."""
wf = getattr(self, "_wf", None)
if wf is None:
return None
rid = self._flow_runs.get(wf.id)
if rid is None:
return None
h = self.manager.get(rid)
if h is None or not h.running:
self._flow_runs.pop(wf.id, None)
return None
return rid
def _outputs_for(self, wf_id: str) -> Dict[str, str]:
"""This flow's accumulated step outputs (kept separate per flow so parallel
runs never seed each other's context)."""
return self._flow_outputs.setdefault(wf_id, {})
def _update_run_btn(self) -> None:
"""Nhãn nút Chạy đổi theo trạng thái luồng đang mở: đang chạy thì thành "Dừng"."""
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
else tr("co4e.run"))
+313
View File
@@ -0,0 +1,313 @@
"""Bố cục ba khung và bảng cấu hình node — R08-T09.
Co4E có bốn lớp điều hướng chồng nhau (dải flow, tab icon bên phải, bảng cấu
hình, canvas). Phần quyết định cái nào hiện lúc nào nằm ở đây, tách khỏi phần
hành vi để sửa bố cục không phải đọc logic chạy flow.
``_apply_narrow_layout`` là chỗ đáng chú ý: màn hẹp thì bảng cấu hình chuyển
từ khung cố định sang lớp phủ, vì ba khung cạnh nhau không vừa 1280px.
"""
from __future__ import annotations
import re
from typing import List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget
from ...core import co4e
from ...i18n import tr
from ...theme import current_palette
from ...ui.co4e_canvas import Co4ECanvas
from ...ui.icons import icon
from ...presentation.co4e.co4e_run_control_widget import RunsPagePanel
class Co4ELayoutMixin:
"""Bố cục ba cột của Co4E Studio: cột trái, khung vẽ ở giữa, bảng thuộc tính
bên phải — cùng dải tab luồng phía trên.
"""
def _build_center(self) -> QWidget:
"""Dựng vùng giữa: dải tab luồng, khung vẽ và trang Flow Status xếp chồng."""
from PySide6.QtWidgets import QStackedWidget, QTabBar
page = QWidget()
lay = QVBoxLayout(page)
# Flow tab bar: a pinned "Runs" tab first (manage every flow run), then a
# browser-style tab per open flow — each keeps its own graph (no mixing).
self.flow_bar = QTabBar()
self.flow_bar.setObjectName("flowTabs")
self.flow_bar.setTabsClosable(True)
self.flow_bar.setMovable(True)
self.flow_bar.setExpanding(False)
self.flow_bar.setDrawBase(False)
# No arrow scroll buttons — when the tabs overflow they scroll inside a
# frameless horizontal scroller you drag left/right (see flow_row below).
self.flow_bar.setUsesScrollButtons(False)
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
# flush, centred). Here we only style the per-tab close (✕) button, which
# QTabBar places centred on the tab's right (see _add_tab_close_button).
_fp = current_palette()
self.flow_bar.setStyleSheet(
"QPushButton#flowTabClose {"
f" border: none; background: transparent; color: {_fp.text_muted};"
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
f" border-radius: {_fp.radius_sm}px; }}"
"QPushButton#flowTabClose:hover {"
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
self.flow_bar.tabCloseRequested.connect(self._close_flow_tab)
# "+" new-flow button styled as the last tab in the strip (browser-style)
# — the + glyph sits inside a tab-shaped button flush with the tabs.
self.flow_add_btn = QPushButton("+")
self.flow_add_btn.setObjectName("flowAddBtn")
self.flow_add_btn.setFixedWidth(34)
self.flow_add_btn.setToolTip(tr("co4e.tt_new_wf"))
self.flow_add_btn.clicked.connect(self._new_workflow)
# Frameless horizontal scroller around the tab strip: overflowing tabs
# scroll (drag) left/right instead of being boxed with arrow buttons.
# The tab bar AND the "+" button are pinned to the SAME fixed height —
# giving the scroll area extra height for its scrollbar (as a previous
# version did) left the tabs top-anchored inside a taller box while the
# "+" button centered across that whole (taller) box, so the two drifted
# out of alignment. Same height on both = always aligned, no centering
# math needed; the scrollbar only appears on overflow (rare) and briefly
# overlaps the tab strip's bottom edge in that case.
_tab_h = self.flow_bar.sizeHint().height()
self.flow_bar.setFixedHeight(_tab_h)
self.flow_add_btn.setFixedHeight(_tab_h)
self.flow_scroll = QScrollArea()
self.flow_scroll.setObjectName("flowTabScroll")
self.flow_scroll.setWidget(self.flow_bar)
self.flow_scroll.setWidgetResizable(True)
self.flow_scroll.setFrameShape(QScrollArea.NoFrame) # no outer frame
self.flow_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.flow_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.flow_scroll.setFixedHeight(_tab_h)
self.flow_scroll.setStyleSheet(
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
# The strip itself is NOT shown any more (see class docstring): flows are
# picked from the WORKFLOWS list on the left, one open at a time. The
# QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
# every open/close/rename path already goes through it — but the user
# never sees or drives it.
self.flow_scroll.setVisible(False)
self.flow_add_btn.setVisible(False)
# Content switches between the Runs table (tab 0) and the flow editor.
self.center_stack = QStackedWidget()
lay.addWidget(self.center_stack, 1)
self.center_stack.addWidget(self._build_runs_page()) # stack 0 = Runs
flow_page = QWidget()
lay = QVBoxLayout(flow_page)
lay.setContentsMargins(0, 0, 0, 0)
bar = QHBoxLayout(); bar.setSpacing(5)
self.name_edit = QLineEdit(self._wf.name)
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
self.name_edit.textChanged.connect(self._on_name_changed)
# "Add" is a labelled button (not a "+" icon) so it isn't mistaken for
# the zoom-in control, which now lives in the canvas's bottom-left overlay.
self.add_step_btn = QPushButton(tr("co4e.add")); self.add_step_btn.setIcon(icon("plus"))
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
self.add_step_btn.clicked.connect(self._add_blank_step)
self.save_btn = QPushButton(tr("co4e.save")); self.save_btn.setIcon(icon("save"))
self.save_btn.setObjectName("primary")
self.save_btn.setToolTip(tr("co4e.tt_save"))
self.save_btn.clicked.connect(lambda: self._save(as_template=False))
self.save_tpl_btn = self._icon_btn("star", "co4e.tt_save_template",
lambda: self._save(as_template=True))
self.mode_combo = QComboBox()
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
for m in co4e.RUN_MODES:
self.mode_combo.addItem(tr(f"co4e.mode.{m}"), m)
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
self.run_btn = QPushButton(tr("co4e.run")); self.run_btn.setIcon(icon("play"))
self.run_btn.setObjectName("primary")
self.run_btn.setToolTip(tr("co4e.tt_run"))
self.run_btn.clicked.connect(self._on_run_clicked)
# The pinned "Runs" tab lost its strip, so it becomes a toggle here —
# one click to the run table and one click back, from either page.
self.runs_btn = QPushButton(tr("co4e.runs_tab"))
self.runs_btn.setIcon(icon("monitoring"))
self.runs_btn.setCheckable(True)
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_btn.toggled.connect(self._show_runs)
bar.addWidget(QLabel(tr("co4e.flow_name")))
bar.addWidget(self.name_edit, 1)
bar.addWidget(self.add_step_btn)
bar.addWidget(self.save_btn)
bar.addWidget(self.save_tpl_btn)
bar.addWidget(self.mode_combo)
bar.addWidget(self.run_btn)
bar.addWidget(self.runs_btn)
lay.addLayout(bar)
self.canvas = Co4ECanvas()
self._build_canvas_overlay()
vsplit = QSplitter(Qt.Vertical)
vsplit.addWidget(self.canvas)
chat_widget = self._build_chat() # default-collapsed (see _build_chat)
vsplit.addWidget(chat_widget)
vsplit.setStretchFactor(0, 1)
self._vsplit = vsplit # so the message panel can collapse/expand
# Messages start collapsed — give the canvas the room from the start,
# not the [540, 220] split that assumed an expanded chat box.
collapsed_h = chat_widget.maximumHeight()
vsplit.setSizes([max(0, 760 - collapsed_h), collapsed_h])
lay.addWidget(vsplit, 1)
self.center_stack.addWidget(flow_page) # stack 1 = flow editor
self.center_stack.setCurrentIndex(1)
return page
def _build_runs_page(self) -> QWidget:
"""The pinned 'Runs' tab: a table of every flow run (name · status · steps
done/total · creator · created) for tracking. Double-click a run to open
that flow's tab with its live status.
Widget construction lives in ``RunsPagePanel`` (presentation/co4e/
co4e_run_control_widget.py); this method just wires the panel's public
attributes to the handler methods that know about ``self`` (``_show_runs``,
``_stop_selected_run``, ...) — the panel itself stays ignorant of ``Co4ETab``.
"""
panel = RunsPagePanel()
self.runs_back_btn = panel.back_btn
self.runs_back_btn.clicked.connect(lambda: self._show_runs(False))
self.runs_title = panel.title_label
self.ws_folder_btn = panel.ws_folder_btn
self.ws_folder_btn.clicked.connect(self._open_workspace_folder)
self._refresh_ws_folder_btn()
self.run_stop_btn = panel.stop_btn
self.run_stop_btn.clicked.connect(self._stop_selected_run)
self.run_rename_btn = panel.rename_btn
self.run_rename_btn.clicked.connect(self._rename_selected_run)
self.run_del_btn = panel.del_btn
self.run_del_btn.clicked.connect(self._delete_selected_run)
self.run_clear_btn = panel.clear_btn
self.run_clear_btn.clicked.connect(lambda: self.manager.clear_finished())
self.runs_table = panel.table
self.runs_table.itemDoubleClicked.connect(self._open_run_from_table)
self.runs_table.customContextMenuRequested.connect(self._runs_context_menu)
return panel
def _wrap_config(self) -> QWidget:
"""Wrap the step-config panel with a header that has an expand/collapse
toggle, so it can be folded away to give the canvas more room."""
container = QWidget()
container.setObjectName("configContainer")
v = QVBoxLayout(container)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
header = QWidget()
hb = QHBoxLayout(header)
hb.setContentsMargins(4, 3, 4, 3)
hb.setSpacing(4)
self.config_toggle_btn = QPushButton()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
self.config_toggle_btn.setFixedSize(26, 24)
self.config_toggle_btn.clicked.connect(self._toggle_config)
self.config_title = QLabel(tr("co4e.config_title"))
self.config_title.setObjectName("hint")
hb.addWidget(self.config_toggle_btn)
hb.addWidget(self.config_title, 1)
v.addWidget(header)
v.addWidget(self.config, 1)
self._cfg_vlayout = v
# Spacers used ONLY while collapsed, to keep the lone toggle icon
# vertically CENTERED in the thin strip (its position no longer jumps to
# the top after collapsing).
self._cfg_top_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self._cfg_bot_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self.config_container = container
return container
def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401
"""Fold the step-config panel on a narrow window, restore it when there
is room again.
Attached from __init__ rather than only on show: this page sits inside a
QTabWidget, whose minimum width is the MAXIMUM over all its pages —
including hidden ones. While Co4E sat unfolded in the background it was
forcing Project and Cowork to be ~1180px wide too.
"""
if narrow != self._config_collapsed:
self._toggle_config()
def _toggle_config(self) -> None:
"""Gập/mở bảng thuộc tính bên phải."""
self._config_collapsed = not self._config_collapsed
v = self._cfg_vlayout
if self._config_collapsed:
w = self.config_container.width()
if w > 60:
self._config_expanded_w = w
self.config.hide()
self.config_title.hide()
self.config_container.setMaximumWidth(34)
self.config_toggle_btn.setIcon(icon("chevron-left"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_expand_config"))
# center the toggle vertically in the collapsed strip
v.insertItem(0, self._cfg_top_spacer)
v.addItem(self._cfg_bot_spacer)
# A maximumWidth alone doesn't make the splitter hand the freed width
# to the canvas — set sizes explicitly so the panel folds to the right.
sizes = self._split.sizes()
if len(sizes) == 3:
freed = sizes[2] - 34
sizes[2] = 34
sizes[1] = max(200, sizes[1] + freed)
self._split.setSizes(sizes)
# Without this the splitter keeps reporting the OLD minimum width,
# and since a QTabWidget's minimum is the maximum over all its pages
# — hidden ones included — Co4E would go on forcing Project and
# Cowork to be 1180px wide even while folded here.
self._refresh_min_width()
else:
v.removeItem(self._cfg_top_spacer)
v.removeItem(self._cfg_bot_spacer)
self.config_container.setMaximumWidth(16777215)
self.config.show()
self.config_title.show()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
sizes = self._split.sizes()
if len(sizes) == 3:
want = self._config_expanded_w
delta = want - sizes[2]
sizes[2] = want
sizes[1] = max(200, sizes[1] - delta)
self._split.setSizes(sizes)
self._refresh_min_width()
def _refresh_min_width(self) -> None:
"""Make the splitter (and everything above it) re-read its minimum."""
self.config_container.updateGeometry()
self._split.refresh()
self._split.updateGeometry()
self.updateGeometry()
def _build_canvas_overlay(self) -> None:
"""Zoom +/− and Fit as a small floating control at the canvas's
bottom-left, stacked vertically. The frame is transparent (so it follows
the dark/light theme — only the buttons carry a themed background) and the
buttons are half-size."""
from PySide6.QtCore import QSize
bar = QFrame()
bar.setObjectName("canvasOverlay")
bar.setStyleSheet("QFrame#canvasOverlay { background: transparent; border: none; }")
v = QVBoxLayout(bar)
v.setContentsMargins(2, 2, 2, 2)
v.setSpacing(3)
self.zoom_in_btn = self._icon_btn("plus", "co4e.tt_zoom_in", lambda: self.canvas.zoom_in())
self.zoom_out_btn = self._icon_btn("minus", "co4e.tt_zoom_out", lambda: self.canvas.zoom_out())
self.fit_btn = self._icon_btn("search", "co4e.fit_tooltip", lambda: self.canvas.fit_view())
for b in (self.zoom_in_btn, self.zoom_out_btn, self.fit_btn):
b.setFixedSize(16, 16) # ~half the previous size
b.setIconSize(QSize(11, 11))
b.setStyleSheet("QPushButton { padding: 0px; }") # keep themed bg, drop padding
v.addWidget(b)
self.canvas.add_overlay(bar)
@@ -0,0 +1,121 @@
"""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:
"""Trang danh sách lượt chạy.
Tự mang nút quay lại vì trang này phủ kín thanh công cụ — nút đã mở nó ra
nằm ngoài màn hình.
"""
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)
+395
View File
@@ -0,0 +1,395 @@
"""Chạy flow và bảng lịch sử lượt chạy — R08-T09.
Ba chế độ chạy: cả flow, một node, hoặc từng bước thủ công. ``_topo_order`` và
``_downstream`` là phần đồ thị — chạy node nào trước, node nào phụ thuộc node
nào.
``_on_manager_event`` là nơi mọi tín hiệu từ bộ chạy nền đổ về; nó dài vì phải
phân nhánh theo loại sự kiện, không tách nhỏ được mà không làm khó đọc hơn.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem
from ...core import co4e
from ...i18n import tr
from ...theme import current_palette
class Co4ERunsMixin:
"""Phần chạy luồng của Co4E Studio: nút Chạy, ba chế độ, và bảng Flow Status.
Nhiều luồng chạy song song được, nên mọi thứ ở đây đều đánh khoá theo
luồng: ``_flow_runs[wf_id]`` là run của từng luồng, ``_run_logs[run_id]``
là nhật ký nhận sự kiện của run đó. Khung vẽ chỉ phản chiếu run của luồng
ĐANG hiện, còn các run khác vẫn chạy nền bình thường.
"""
def _current_mode(self) -> str:
"""Chế độ chạy đang chọn: 'auto' (mặc định), 'plan' hay 'manual'."""
return self.mode_combo.currentData() or "auto"
def _on_mode_changed(self, *_a) -> None:
# switching mode resets any in-progress manual sequence
"""Đổi chế độ thì huỷ chuỗi chạy thủ công đang dở và trả nhãn nút về 'Chạy'."""
self._manual_active = False
self._manual_order = []
self._manual_idx = 0
if self._cur_run_id() is None:
self.run_btn.setText(tr("co4e.run"))
def _on_run_clicked(self) -> None:
# THIS flow's run is active → interrupt it (other flows keep running).
"""Bấm nút Chạy: luồng NÀY đang chạy thì dừng nó, chưa chạy thì bắt đầu theo
chế độ đang chọn.
Chỉ dừng run của luồng đang mở — các luồng khác không bị đụng tới.
"""
cur = self._cur_run_id()
if cur is not None:
self.manager.stop(cur)
return
mode = self._current_mode()
if mode == "manual":
self._manual_run_or_advance()
else:
self._start_canvas_run(plan_mode=(mode == "plan"))
def _start_canvas_run(self, *, plan_mode: bool, only: Optional[set] = None,
seed: Optional[Dict[str, str]] = None) -> None:
"""Bắt đầu chạy luồng trên khung vẽ.
``only`` giới hạn ở một nhóm bước (chạy lại một nhánh); để trống thì chạy
cả luồng và xoá sạch trạng thái/đầu ra cũ trước. ``seed`` là đầu ra sẵn có
đưa vào làm ngữ cảnh, để chạy tiếp một nhánh không mất kết quả phía trên.
"""
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
wf_id = self._wf.id
if only is None:
self.canvas.reset_statuses()
self._outputs_for(wf_id).clear()
self._plan_bubble = None
self._append_chat("system", tr("co4e.run_started", name=self._wf.name))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(), plan_mode=plan_mode,
only_nodes=only, seed_outputs=seed or dict(self._outputs_for(wf_id)))
self._flow_runs[wf_id] = run_id # track THIS flow's run (parallel-safe)
self._run_logs[run_id] = self.chat_log # route its events to THIS flow's log
self.run_btn.setText(tr("co4e.interrupt"))
def _run_single(self, node_id: str) -> None:
"""Run one step (config panel "Run this step") with upstream context."""
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only={node_id}, seed=dict(self._outputs_for(self._wf.id)))
def _run_from(self, node_id: str) -> None:
"""Chạy lại từ một bước trở đi — tức bước đó và mọi bước phía sau nó."""
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only=self._downstream(node_id), seed=dict(self._outputs_for(self._wf.id)))
def _downstream(self, node_id: str) -> set:
"""Tập id các bước nằm sau ``node_id`` trên đồ thị (kể cả chính nó)."""
adj: Dict[str, List[str]] = {}
for e in self.canvas.edges():
adj.setdefault(e.source, []).append(e.target)
seen, stack = set(), [node_id]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
stack.extend(adj.get(cur, []))
return seen
def _manual_run_or_advance(self) -> None:
"""Chế độ thủ công: lần bấm đầu khởi tạo chuỗi bước, các lần sau đi tiếp một bước."""
if not self._manual_active:
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
self.canvas.reset_statuses()
self._outputs_for(self._wf.id).clear()
self._plan_bubble = None
self._manual_order = self._topo_order()
self._manual_idx = 0
self._manual_active = True
self._append_chat("system", tr("co4e.manual_started", name=self._wf.name))
self._manual_step()
def _manual_step(self) -> None:
"""Chạy đúng một bước trong chuỗi thủ công, hoặc kết thúc nếu đã hết bước."""
if self._manual_idx >= len(self._manual_order):
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"))
return
nid = self._manual_order[self._manual_idx]
label = next((n.data.label for n in self.canvas.nodes() if n.id == nid), nid)
self._append_chat("system", tr("co4e.manual_step",
i=self._manual_idx + 1, n=len(self._manual_order), label=label))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(),
plan_mode=False, only_nodes={nid}, seed_outputs=dict(self._outputs_for(self._wf.id)),
manual=True)
self._flow_runs[self._wf.id] = run_id
self._run_logs[run_id] = self.chat_log
self.run_btn.setText(tr("co4e.interrupt"))
def _topo_order(self) -> List[str]:
"""Thứ tự chạy các bước: theo lớp phụ thuộc trước, trong cùng lớp thì theo"""
nodes = self.canvas.nodes()
edges = self.canvas.edges()
waves = co4e.compute_waves(nodes, edges)
y = {n.id: n.y for n in nodes}
return sorted((n.id for n in nodes), key=lambda nid: (waves.get(nid, 0), y.get(nid, 0)))
def _on_manager_event(self, run_id: str, ev: dict) -> None:
# Per-flow routing: every run's events go to ITS OWN flow log (so parallel
# runs never mix), and the canvas mirrors ONLY the run whose flow is the
# one currently shown. Flow Status refreshes on its own via `changed`.
"""Nhận sự kiện của một run và chuyển về đúng nơi."""
h = self.manager.get(run_id)
run_wf = h.wf_id if h is not None else None
log = self._run_logs.get(run_id) or self.chat_log
shown = getattr(self, "_wf", None) is not None and run_wf == self._wf.id
t = ev.get("type")
if t == "node_status":
if shown:
self.canvas.update_node_status(ev.get("node_id"), ev.get("status"))
elif t == "node_output":
if run_wf is not None:
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
label = ev["node_id"]
if shown:
label = next((n.data.label for n in self.canvas.nodes() if n.id == ev["node_id"]),
ev["node_id"])
elif h is not None and h.wf is not None:
label = next((n.data.label for n in h.wf.nodes if n.id == ev["node_id"]), ev["node_id"])
if ev.get("output"):
bub = self._append_chat("assistant", f"**{label}**\n\n{ev['output']}", log=log)
# Per-message token/cost footer (↓in ↑out ▤ctx $cost), like Cowork.
self._apply_usage(bub, run_wf, ev.get("usage"))
elif t == "node_diff":
self._append_diff(ev.get("title", ""), ev.get("diff", ""), log=log)
elif t == "node_plan":
self._append_plan(ev.get("steps") or [], log=log)
elif t == "node_tool":
if not ev.get("ok", True):
# A single failed tool call isn't a step failure — the agent is told
# to recover and continue, so show it as a neutral notice (not a red
# "Error" that reads like the whole flow crashed).
self._append_chat("system", "⚠ " + tr("co4e.tool_failed", name=ev.get("name", "")), log=log)
elif t in ("run_done", "run_error"):
# Drop THIS flow's run tracking (other flows keep running in parallel).
if run_wf is not None and self._flow_runs.get(run_wf) == run_id:
self._flow_runs.pop(run_wf, None)
self._run_logs.pop(run_id, None)
if self._manual_active and shown:
self._manual_idx += 1
self._manual_step()
else:
if shown:
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"), log=log)
# Clickable link to the output folder so files are one click away.
out = (h.out_dir if h is not None and h.out_dir else "") or str(self._flow_output_root())
try:
log.add_folder_link(out, tr("co4e.open_output_link"))
log.scroll_to_bottom()
except Exception: # noqa: BLE001 - link is a nicety, never fatal
pass
self._notify_run_finished(run_id) # popup: the flow finished
if not shown and h is not None:
self.status_message.emit(tr("co4e.bg_done", name=h.name, status=h.status))
def _notify_run_finished(self, run_id: str) -> None:
"""Show a non-blocking popup when a flow finishes (done / error / stopped),
so the user is notified even if they're on another screen."""
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QMessageBox
if not hasattr(self, "_run_popups"):
self._run_popups = []
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if h.status == "error" else QMessageBox.Information)
box.setWindowTitle(tr("co4e.run_done_title"))
box.setText(tr("co4e.run_done_popup", name=h.name,
status=tr("co4e.status." + h.status)))
box.setStandardButtons(QMessageBox.Ok)
box.setModal(False) # non-blocking notification
box.setAttribute(Qt.WA_DeleteOnClose, True)
box.finished.connect(
lambda _r=0, b=box: self._run_popups.remove(b) if b in self._run_popups else None)
self._run_popups.append(box) # keep a ref so it isn't GC'd
box.show()
def _refresh_runs(self) -> None:
# Rebuild the always-fresh Runs table from the manager (single source of truth).
"""Dựng lại bảng Flow Status từ dữ liệu của manager."""
if not hasattr(self, "runs_table"):
return
p = current_palette()
color = {"running": p.accent, "done": p.success, "error": p.danger,
"stopped": p.text_muted}
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
# chronological insertion order, so reverse it for display).
runs = list(reversed(self.manager.runs()))
t = self.runs_table
# Preserve the selected run across the rebuild by its id (row indices shift
# as runs are added/deleted, so a row-index restore would jump).
sel_item = t.item(t.currentRow(), 0) if t.currentRow() >= 0 else None
sel_id = sel_item.data(Qt.UserRole) if sel_item is not None else None
t.setRowCount(len(runs))
sel_row = -1
for r, h in enumerate(runs):
vals = [f"{dots.get(h.status, '•')} {h.name}", tr("co4e.status." + h.status),
h.progress_text(), h.created_by or "-", h.created_at or "-"]
for c, val in enumerate(vals):
it = QTableWidgetItem(str(val))
if c == 0:
it.setData(Qt.UserRole, h.id)
if c == 1:
from ...ui.co4e_tab import _qcolor
it.setForeground(_qcolor(color.get(h.status, p.text)))
t.setItem(r, c, it)
if h.id == sel_id:
sel_row = r
if sel_row >= 0:
t.setCurrentCell(sel_row, 0)
# The sidebar's short run list is the same data — refresh it together.
self._refresh_side_runs()
# Active-run count, on the sidebar heading now that the tab strip is gone.
n = self.manager.active_count()
label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
if hasattr(self, "flow_bar"):
self.flow_bar.setTabText(0, label)
head = (self._sections.get("co4e.runs_tab") or (None,))[0]
if head is not None:
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
def _stop_selected_run(self) -> None:
"""Dừng run đang chọn; không chọn gì thì dừng toàn bộ run của workspace này."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.manager.stop_all()
return
self.manager.stop(it.data(Qt.UserRole))
def _delete_selected_run(self) -> None:
"""Delete the selected run from the Flow Status history (a running one is
stopped first). Removes just that single entry."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id) # stop tracking it per-flow if we were
if h is not None and self._flow_runs.get(h.wf_id) == run_id:
self._flow_runs.pop(h.wf_id, None)
self._run_logs.pop(run_id, None)
self.manager.remove(run_id) # emits `changed` → _refresh_runs
def _runs_context_menu(self, pos) -> None:
"""Menu chuột phải trên bảng Flow Status: mở, đổi tên, chạy lại, dừng, xoá."""
from PySide6.QtWidgets import QMenu
item = self.runs_table.itemAt(pos)
if item is None:
return
self.runs_table.selectRow(item.row())
menu = QMenu(self)
menu.addAction(tr("co4e.open_run"),
lambda: self._open_run_from_table(self.runs_table.item(item.row(), 0)))
it0 = self.runs_table.item(item.row(), 0)
rid = it0.data(Qt.UserRole) if it0 is not None else None
menu.addAction(tr("co4e.open_output"), lambda: self._open_run_output_folder(rid))
menu.addAction(tr("co4e.rename_run"), self._rename_selected_run)
menu.addAction(tr("co4e.delete_run"), self._delete_selected_run)
menu.exec(self.runs_table.viewport().mapToGlobal(pos))
def _open_run_output_folder(self, run_id) -> None:
"""Open the workspace folder a specific run wrote its files into."""
from ...ui.osutil import open_location
h = self.manager.get(run_id) if run_id else None
path = Path(h.out_dir) if (h is not None and h.out_dir) else self._flow_output_root()
if not path.exists():
path = self._flow_output_root()
try:
path.mkdir(parents=True, exist_ok=True)
except OSError:
pass
open_location(str(path))
def _rename_selected_run(self) -> None:
"""Rename the selected run in Flow Status — updates the run entry AND its
underlying saved flow / open tab so the name stays consistent everywhere."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QInputDialog
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip()
if not ok or not new or new == h.name:
return
self.manager.rename(run_id, new) # run entry + snapshot (→ refresh)
# Keep the underlying saved flow + any open tab in sync.
wf = co4e.get_workflow(h.wf_id)
if wf is not None:
wf.name = new
co4e.save_workflow(wf)
self._reload_sidebar()
for i, f in enumerate(self._flows):
if f.id == h.wf_id:
f.name = new
self.flow_bar.setTabText(i + 1, new)
break
if self._wf.id == h.wf_id and self.name_edit.text() != new:
self.name_edit.setText(new) # updates _wf.name + active tab text
def _run_selected_in_background(self) -> None:
"""Chạy luồng đang chọn trong danh sách ở chế độ nền, không mở nó lên khung vẽ."""
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
# Used to jump the sidebar back to the Workflows tab; with one column
# there is nothing to jump to — show the run that just started instead.
self._refresh_side_runs()
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _rerun_run_item(self, item) -> None:
"""Double-click a run in the history → run that flow again (in background)."""
h = self.manager.get(item.data(Qt.UserRole))
if h is None:
return
wf = self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _open_run_from_table(self, item) -> None:
"""Double-click a run row in the Runs tab → open that flow's tab and show
its live status (opens/focuses the tab; _open_flow reflects the run)."""
id_item = self.runs_table.item(item.row(), 0)
if id_item is None:
return
h = self.manager.get(id_item.data(Qt.UserRole))
if h is None:
return
# Prefer the flow the run kept a reference to (works even after its tab was
# closed or if it was never saved); fall back to resolving by id.
wf = getattr(h, "wf", None) or self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self._open_flow(wf)
# reflect this run's step statuses (done/error/running) on the canvas
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
self.status_message.emit(tr("co4e.viewing_flow", name=wf.name))
+270
View File
@@ -0,0 +1,270 @@
"""Cột trái: thư viện workflow, agent, skill — R08-T09.
Bốn mục gập được (WORKFLOWS / AGENTS / SKILLS / FLOW STATUS). Trạng thái gập
của từng mục là thứ người dùng đặt rồi mong nó giữ nguyên, nên nó nằm trong
cấu hình chứ không phải trong widget.
"""
from __future__ import annotations
import re
from typing import List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget
from ...core import co4e, skills as skills_mod
from ...i18n import tr
from ...ui.icons import icon
from ...presentation.co4e.agent_list_panel import AgentListPanel
from ...presentation.co4e.co4e_chat_view import _skill_names
from ...presentation.co4e.palette_list import _PaletteList
from ...presentation.co4e.skills_list_panel import SkillsListPanel
class Co4ESidebarMixin:
"""Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status."""
def _build_sidebar(self) -> QWidget:
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
# at once, so "what can I drag onto the canvas" is answered by looking
# rather than by clicking through three unlabeled tabs.
# A vertical splitter, not a fixed stack: on a short window four stacked
# lists otherwise squeeze down to one visible row each. The splitter
# hands out the available height by weight and lets the user re-balance
# it by dragging; each list keeps a small minimum so none disappears.
"""Dựng cột trái: MỘT cột, bốn mục có tên, không dùng tab icon.
Mọi danh sách hiện cùng lúc nên câu hỏi "kéo được gì lên khung vẽ" trả lời
bằng cách NHÌN, không phải bấm qua ba tab không nhãn. Dùng splitter dọc chứ
không xếp cứng: cửa sổ thấp thì bốn danh sách chồng nhau sẽ bị bóp còn đúng
một dòng mỗi cái; splitter chia chiều cao theo trọng số và cho người dùng
tự cân lại.
"""
self._sections: dict = {}
self.sidebar = QWidget()
outer_col = QVBoxLayout(self.sidebar)
outer_col.setContentsMargins(6, 6, 6, 6)
outer_col.setSpacing(0)
self.side_split = QSplitter(Qt.Vertical)
self.side_split.setChildrenCollapsible(False)
self.side_split.setHandleWidth(8)
outer_col.addWidget(self.side_split, 1)
class _Col:
"""Adapter so the section builders below read the same as before."""
def __init__(self, split):
"""Bọc một ``QSplitter`` để phần còn lại thao tác với nó như một cột."""
self._split = split
def addWidget(self, w, stretch=1):
"""Thêm một mục vào splitter dọc kèm trọng số chia chiều cao.
Lớp bọc nhỏ này cho phép dùng splitter ở chỗ mã cũ đang gọi theo API của
layout, nên phần dựng mục không phải sửa.
"""
self._split.addWidget(w)
self._split.setStretchFactor(self._split.count() - 1, stretch)
col = _Col(self.side_split)
# --- WORKFLOWS ---------------------------------------------------
self.wf_new_btn = QPushButton(tr("co4e.new"))
self.wf_new_btn.setIcon(icon("plus"))
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
self.wf_new_btn.setObjectName("co4eSectionAction")
self.wf_new_btn.setFlat(True)
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
self.wf_new_btn.clicked.connect(self._new_workflow)
wf_body = QWidget(); wl = QVBoxLayout(wf_body)
wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
# double-click loads it onto the canvas.
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
self.wf_list.setToolTip(tr("co4e.drag_hint"))
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
wl.addWidget(self.wf_list, 1)
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
wf_btns.addWidget(b)
wf_btns.addStretch(1)
wl.addLayout(wf_btns)
# Its own row: sharing one line with the three icon buttons cut "Chạy
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
wl.addWidget(self.wf_runbg_btn)
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
# --- AGENTS ------------------------------------------------------
# Widget cua khu vuc nay da doi sang AgentListPanel (xem
# presentation/co4e/agent_list_panel.py); o day chi con giu
# ag_new_btn/agent_list/ag_edit_btn/ag_del_btn nhu 4 ten thuoc tinh cu
# va tu noi signal - dung nguyen tac panel khong tu wire, Co4ETab moi
# biet _new_agent/_edit_agent/_delete_agent.
self._agent_panel = AgentListPanel()
self.ag_new_btn = self._agent_panel.new_btn
self.ag_new_btn.clicked.connect(self._new_agent)
self.agent_list = self._agent_panel.list_widget
self.ag_edit_btn = self._agent_panel.edit_btn
self.ag_edit_btn.clicked.connect(self._edit_agent)
self.ag_del_btn = self._agent_panel.del_btn
self.ag_del_btn.clicked.connect(self._delete_agent)
col.addWidget(self._section("co4e.tab_agents", self._agent_panel, self.ag_new_btn), 3)
# --- SKILLS ------------------------------------------------------
# Widget cua khu vuc nay da doi sang SkillsListPanel (xem
# presentation/co4e/skills_list_panel.py); o day chi con giu
# sk_manage_btn/skill_list nhu 2 ten thuoc tinh cu va tu noi signal -
# dung nguyen tac panel khong tu wire, Co4ETab moi biet _manage_skills.
self._skills_panel = SkillsListPanel()
self.sk_manage_btn = self._skills_panel.manage_btn
self.sk_manage_btn.clicked.connect(self._manage_skills)
self.skill_list = self._skills_panel.list_widget
col.addWidget(self._section("co4e.tab_skills", self._skills_panel, self.sk_manage_btn), 2)
# --- RUNS --------------------------------------------------------
# A short, always-visible view of the same runs the Flow Status page
# tables in full. Clicking one opens that page with the run selected.
# Icon only: the heading beside it already reads FLOW STATUS, and the
# label was long enough to be cut in half in a narrow sidebar.
self.runs_more_btn = QPushButton()
self.runs_more_btn.setIcon(icon("chevron-right"))
self.runs_more_btn.setFixedWidth(30)
self.runs_more_btn.setFlat(True)
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
self.runs_side_list = QListWidget()
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
rl.addWidget(self.runs_side_list, 1)
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
# Small enough that all four still fit on a laptop screen, large enough
# that each shows more than a single row.
for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
lst.setMinimumHeight(56)
return self.sidebar
def _refresh_side_runs(self) -> None:
"""Mirror the newest runs into the sidebar's short list."""
lst = getattr(self, "runs_side_list", None)
if lst is None:
return
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
lst.clear()
for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
f" {h.progress_text()}")
it.setData(Qt.UserRole, h.id)
it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
lst.addItem(it)
def _on_side_run_clicked(self, item) -> None:
"""Open the full Flow Status page with this run selected."""
run_id = item.data(Qt.UserRole)
self._show_runs(True)
for r in range(self.runs_table.rowCount()):
cell = self.runs_table.item(r, 0)
if cell is not None and cell.data(Qt.UserRole) == run_id:
self.runs_table.setCurrentCell(r, 0)
break
def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
stretch: int = 1) -> QWidget:
"""One named, foldable section of the sidebar column.
Replaces the three icon-only tabs: all the lists are visible at once
(WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
action that belongs to it. Clicking the heading folds the section, so a
narrow window can still get to everything.
"""
box = QWidget()
v = QVBoxLayout(box)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(2)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
head = QPushButton()
head.setObjectName("co4eSectionHdr")
head.setCheckable(True)
head.setChecked(True)
head.setCursor(Qt.PointingHandCursor)
head.setFlat(True)
head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
row.addWidget(head, 1)
if action is not None:
row.addWidget(action, 0)
v.addLayout(row)
v.addWidget(body, 1)
self._sections[key] = (head, body, stretch)
self._sync_section_arrow(key)
return box
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
"""Fold/unfold a section AND give its height back to the others.
Inside a splitter, hiding the body is not enough — the pane keeps its
share of the height, so folding would free nothing. Clamping the whole
section to its header height makes the splitter re-deal the space.
"""
body.setVisible(on)
if on:
box.setMaximumHeight(16777215)
else:
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
self._sync_section_arrow(key)
def _sync_section_arrow(self, key: str) -> None:
"""Cập nhật mũi tên gập/mở và nhãn viết hoa của một mục ở cột trái."""
head, _body, _s = self._sections[key]
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
"""Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục."""
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
b.setFixedWidth(34)
b.clicked.connect(slot)
return b
def _reload_sidebar(self) -> None:
"""Nạp lại cả bốn danh sách ở cột trái: luồng, agent, skill và Flow Status."""
self.wf_list.clear()
for wf in co4e.list_workflows():
tag = tr("co4e.template") if wf.is_template else tr("co4e.saved")
it = QListWidgetItem(icon("workspaces"), f"{wf.name} · {tag}")
it.setData(Qt.UserRole, ("saved", wf.id))
it.setData(Qt.UserRole + 2, {"kind": "workflow", "workflow": co4e.workflow_to_dict(wf)})
self.wf_list.addItem(it)
# Agents: only the Parallel fan-out node + the user's own custom agents
# (create your own with "+ New agent"; drag onto the canvas). The blank
# "New Step" palette entry was removed — use the toolbar "+ Add" instead.
self.agent_list.clear()
self.agent_list.addItem(self._palette_item(
tr("co4e.parallel_node"), "server",
{"variant": "parallel", "label": "Parallel", "role": "PARALLEL", "icon": "server",
"sub_agents": []}))
for ca in co4e.list_custom_agents():
step = co4e.Step(label=ca.name, agent_slug=co4e.slugify(ca.name), role=ca.role or "AGENT",
icon=ca.icon, instructions=ca.instructions,
context=getattr(ca, "context", ""), model=ca.model,
permission_preset=ca.permission_preset, skills=list(ca.skills),
attachments=list(getattr(ca, "attachments", []) or []))
it = self._palette_item(f"{ca.name} · {ca.role} · {tr('co4e.custom')}", ca.icon or "robot",
co4e._step_dict(step))
it.setData(Qt.UserRole + 1, ca.id)
self.agent_list.addItem(it)
# Skills
self.skill_list.clear()
for name in _skill_names():
content = skills_mod.skill_prefix_for(name)
payload = co4e._step_dict(co4e.Step(
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
instructions=content, skills=[name]))
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
@staticmethod
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
"""Dựng một mục trong bảng nguyên liệu, mang sẵn payload để kéo-thả lên khung vẽ."""
it = QListWidgetItem(icon(icon_name), text)
it.setData(Qt.UserRole, payload)
return it
+80
View File
@@ -0,0 +1,80 @@
"""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.
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``presentation/shell/bootstrap.py`` gọi ``build_co4e_tab`` thay cho việc dựng thẳng ``ui.co4e_tab.Co4ETab``.
Để dormant thì sao: Hạn lắp đã ghi trong file là 28/08 và đã qua. Factory
không ai gọi thì chữ ký của nó không còn được kiểm chứng bởi bất cứ đường
chạy thật nào.
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
đọc theo — đừng sửa ngày để làm im lời nhắc.
"""
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)
+186
View File
@@ -0,0 +1,186 @@
"""Tạo, sửa, đổi tên, xoá, nhân bản workflow — R08-T09.
Chỉ thao tác trên danh sách. Phần chạy một workflow nằm ở ``co4e_runs.py``,
phần vẽ node nằm ở canvas.
"""
from __future__ import annotations
import re
from typing import List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu
from ...core import co4e
from ...i18n import tr
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EWorkflowCrudMixin:
"""Phần tạo/mở/lưu/xoá luồng của Co4E Studio.
Tách khỏi ``Co4ETab`` vì đây là nhóm việc trên *danh sách luồng đã lưu*,
không phải trên khung vẽ đang mở — dù hai bên nói chuyện với nhau liên tục.
"""
def _apply_workflow(self, wf: co4e.Workflow) -> None:
"""Đưa một luồng lên khung vẽ và chuyển hội thoại sang nhật ký của chính nó.
KHÔNG xoá ``_flow_outputs``: mỗi luồng giữ ngữ cảnh tích luỹ riêng, đổi
tab mà xoá là mất kết quả của luồng khác. Luồng cũ xếp dọc thì tự bẻ lại
thành trái→phải cho khớp bố cục hiện tại.
"""
self._wf = wf
# Per-flow outputs are kept in self._flow_outputs[wf.id] — do NOT clear
# here (switching tabs must not wipe another flow's accumulated context).
# Switch the visible conversation to THIS flow's own log.
self.chat_stack.setCurrentWidget(self._ensure_flow_log(wf.id))
self.name_edit.setText(wf.name)
self.canvas.load(wf.nodes, wf.edges)
self.config.clear_step()
if wf.nodes:
self.canvas.relayout_if_vertical() # convert old top-down flows to left→right
self.canvas.fit_view()
self._update_run_btn() # reflect THIS flow's run state
self._refresh_usage_total() # show THIS flow's token/cost total
def _new_workflow(self) -> None:
"""Tạo luồng trống mới, đưa con trỏ vào ô tên và báo trạng thái.
Trước đây bấm nút này khi khung vẽ đã có sẵn một luồng trống chưa đặt tên
sẽ tạo ra một luồng trống y hệt — đúng logic nhưng nhìn không thấy gì đổi,
nên nút bị hiểu là hỏng. Giờ nói rõ vừa xảy ra chuyện gì và đặt con trỏ
vào đúng việc tiếp theo: đặt tên.
"""
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
# Pressing this while the canvas already holds an empty untitled flow
# produced an identical empty untitled flow — correct, and completely
# invisible, so the button read as broken. Say what happened and put the
# cursor where the next thing to do is: naming it.
self.name_edit.setFocus()
self.name_edit.selectAll()
self.status_message.emit(tr("co4e.new_flow_ready"))
def _selected_wf(self) -> Optional[co4e.Workflow]:
"""Materialise the selected saved-flow row into a Workflow."""
item = self.wf_list.currentItem()
if item is None:
return None
_kind, ident = item.data(Qt.UserRole)
return co4e.get_workflow(ident)
def _load_selected_workflow(self, *_a) -> None:
"""Mở luồng đang chọn trong danh sách (hoặc chuyển sang tab của nó nếu đã mở)."""
wf = self._selected_wf()
if wf is not None:
self._open_flow(wf) # open (or focus) its browser-style tab
def _edit_selected_workflow(self) -> None:
"""Mở luồng đang chọn để sửa; chưa chọn gì thì nhắc người dùng chọn."""
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self._open_flow(wf)
def _duplicate_selected_workflow(self) -> None:
"""Nhân bản luồng đang chọn thành một luồng mới rồi nạp lại danh sách."""
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
dup = co4e.duplicate_workflow(wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.duplicated_msg", name=dup.name))
def _wf_context_menu(self, pos) -> None:
"""Menu chuột phải trên danh sách luồng: sửa, đổi tên, nhân bản, chạy nền, xoá."""
lw = self.wf_list
item = lw.itemAt(pos)
if item is None:
return
lw.setCurrentItem(item)
_kind, ident = item.data(Qt.UserRole)
menu = QMenu(lw)
act_edit = menu.addAction(icon("edit"), tr("co4e.edit"))
act_rename = menu.addAction(icon("edit"), tr("co4e.rename"))
act_dup = menu.addAction(icon("branch"), tr("co4e.duplicate"))
act_run = menu.addAction(icon("play"), tr("co4e.run_bg"))
act_del = menu.addAction(icon("trash"), tr("co4e.delete"))
chosen = menu.exec(lw.viewport().mapToGlobal(pos))
if chosen is act_edit:
self._edit_selected_workflow()
elif chosen is act_rename:
self._rename_workflow(ident)
elif chosen is act_dup:
self._duplicate_selected_workflow()
elif chosen is act_run:
self._run_selected_in_background()
elif chosen is act_del:
self._delete_selected_workflow()
def _rename_workflow(self, ident: str) -> None:
"""Rename a saved flow in place (e.g. to match its function/task)."""
wf = co4e.get_workflow(ident)
if wf is None:
return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name = (name or "").strip()
if not ok or not name:
return
wf.name = name
co4e.save_workflow(wf)
if self._wf.id == ident:
self.name_edit.setText(name)
self._wf.name = name
self._reload_sidebar()
self.status_message.emit(tr("co4e.renamed_msg", name=name))
def _delete_selected_workflow(self) -> None:
"""Xoá luồng đang chọn khỏi đĩa rồi nạp lại danh sách."""
item = self.wf_list.currentItem()
if item is None:
return
_kind, ident = item.data(Qt.UserRole)
co4e.delete_workflow(ident)
self._reload_sidebar()
def _sync_wf_from_canvas(self) -> None:
"""Chép node, cạnh và tên từ khung vẽ về đối tượng luồng trước khi lưu."""
self._wf.nodes = self.canvas.nodes()
self._wf.edges = self.canvas.edges()
self._wf.name = self.name_edit.text().strip() or tr("co4e.untitled")
def _save(self, as_template: bool) -> None:
"""Lưu luồng đang mở; ``as_template`` đánh dấu nó là mẫu dùng lại được."""
self._sync_wf_from_canvas()
self._wf.is_template = as_template
co4e.save_workflow(self._wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.saved_msg", name=self._wf.name))
def _autosave(self) -> None:
"""Tự lưu sau mỗi thay đổi trên khung vẽ — nhưng chỉ với luồng ĐÃ tồn tại
trên đĩa, để luồng nháp chưa đặt tên không tự nhảy vào danh sách.
"""
if co4e.get_workflow(self._wf.id) is not None:
self._sync_wf_from_canvas()
co4e.save_workflow(self._wf)
def _on_name_changed(self, text: str) -> None:
"""Gõ tên mới thì cập nhật luôn nhãn tab của luồng đang mở."""
self._wf.name = text.strip() or tr("co4e.untitled")
self._sync_active_flow_tab_text()
def _add_blank_step(self) -> None:
"""Thêm một bước trống vào giữa khung nhìn hiện tại."""
self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center()))
def _on_node_selected(self, node_id: str) -> None:
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập."""
for n in self.canvas.nodes():
if n.id == node_id:
self.config.load_step(node_id, n.data, _skill_names())
if self._config_collapsed:
self._toggle_config()
return
def _on_config_changed(self) -> None:
"""Sửa thuộc tính bước thì vẽ lại mọi node (nhãn/model có thể đổi) rồi tự lưu."""
for n in self.canvas.nodes():
self.canvas.refresh_node(n.id)
self._autosave()
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
"""Resolve a flow id to a Workflow — saved, or the open canvas."""
wf = co4e.get_workflow(wf_id)
if wf is not None:
return wf
if self._wf.id == wf_id:
self._sync_wf_from_canvas()
return self._wf
return None
@@ -0,0 +1,211 @@
"""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:
"""Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó)."""
if self._step is None:
return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names()
if names:
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names, 0, True) # editable: can type a new one
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:
"""Xoá sub-agent đang chọn khỏi bước."""
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:
"""Đính kèm tệp vào bước đang chọn."""
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:
"""Gỡ tệp đính kèm đang chọn khỏi bước."""
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):
"""Chạy nền: nhờ model soạn thử prompt cho agent theo tên, vai trò và gợi ý."""
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):
"""Đổ prompt vừa soạn vào ô chỉ dẫn (``_on_edit`` sẽ tự lưu lại)."""
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:
"""Nạp danh sách model đang chạy được vào ô chọn của bước, ở luồng nền."""
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):
"""Chạy nền: hỏi mọi provider danh sách model đang sống."""
return preview_ai.fetch_live_models(ctx)
def done(result: dict):
"""Gộp model của mọi provider vào ô chọn, giữ nguyên thứ đang chọn."""
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()
+308
View File
@@ -0,0 +1,308 @@
"""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):
"""Bảng thuộc tính bên phải khung vẽ Co4E: sửa nhãn, vai trò, model, chỉ dẫn,
skill, sub-agent và tệp đính kèm của một bước.
"""
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):
"""Panel cấu hình cho bước đang chọn.
``_loading`` chặn tín hiệu trong lúc đổ dữ liệu vào form: không có nó thì
chính việc đổ dữ liệu sẽ bị hiểu là người dùng vừa sửa.
"""
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:
"""Nạp một bước lên bảng.
Bật cờ ``_loading`` trong lúc điền để việc đặt giá trị không bị hiểu nhầm là
người dùng vừa sửa và kích hoạt tự lưu.
"""
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:
"""Xoá bảng khi không có bước nào được chọn."""
self._step = None
self._node_id = ""
self.setEnabled(False)
# ---- edits write back to the Step -------------------------------------
def _on_edit(self, *_a) -> None:
"""Người dùng sửa một trường: ghi vào bước rồi báo ra ngoài để vẽ lại node và tự lưu."""
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()
+59
View File
@@ -0,0 +1,59 @@
"""``_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):
"""Danh sách nguyên liệu chỉ để kéo ra khung vẽ — không nhận thả vào."""
super().__init__(parent)
self._payload_role = payload_role
self.setDragEnabled(True)
self.setDragDropMode(QListWidget.DragOnly)
def startDrag(self, _actions): # noqa: N802
"""Bắt đầu kéo: gói dữ liệu của mục đang chọn vào MIME riêng của Co4E, để khung
vẽ nhận ra và dựng đúng loại node.
"""
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)
+64
View File
@@ -0,0 +1,64 @@
"""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:
"""Danh sách skill ở cột trái Co4E Studio, kèm nút quản lý.
Không nối ``clicked`` ở đây: bên gọi (``Co4ETab``) tự quyết định nút ấy mở
cái gì.
"""
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)
+146
View File
@@ -0,0 +1,146 @@
"""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
"""Bấm trái vào tiêu đề mục thì gập/mở mục đó."""
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.
"""Tính lại chiều cao cố định mỗi lần nhãn thật sự hiện ra.
``fontMetrics()`` lúc dựng chỉ phản ánh cỡ chữ trong QSS nếu style đã được
áp xong — ở lần vẽ ĐẦU TIÊN của màn Co4E thì đôi khi chưa, nên chiều cao
tính trong ``_add_section`` bị sai (quá cao) cho tới khi có gì đó buộc bố
cục tính lại. Tính lại ở đây thì lần vẽ đầu không bao giờ còn lệch.
"""
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:
"""Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều
trường không bị cắt cụt.
"""
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:
"""Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng."""
nonlocal is_open
is_open = not is_open
header.setText(f"{'▼' if is_open else '▶'} {title}")
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