refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%

Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
+1
View File
@@ -53,6 +53,7 @@ class AgentListPanel(QWidget):
"""
def __init__(self, parent: QWidget | None = None) -> None:
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
super().__init__(parent)
self.new_btn = QPushButton(tr("co4e.new"))
self.new_btn.setIcon(icon("plus"))
+12
View File
@@ -28,10 +28,15 @@ _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)
@@ -74,6 +79,10 @@ def _seg_hits_rect(p1: QPointF, p2: QPointF, rect: QRectF) -> bool:
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):
@@ -122,5 +131,8 @@ def _ortho_path(src: QPointF, dst: QPointF, r: float = _CORNER_R) -> QPainterPat
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] + "…"
@@ -49,6 +49,11 @@ class _CanvasInteractionMixin:
# ---- 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()
@@ -56,6 +61,7 @@ class _CanvasInteractionMixin:
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()
@@ -63,10 +69,16 @@ class _CanvasInteractionMixin:
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.
@@ -74,11 +86,19 @@ class _CanvasInteractionMixin:
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
@@ -92,16 +112,22 @@ class _CanvasInteractionMixin:
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:
@@ -117,6 +143,7 @@ class _CanvasInteractionMixin:
# ---- 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()
@@ -126,6 +153,7 @@ class _CanvasInteractionMixin:
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
@@ -137,6 +165,7 @@ class _CanvasInteractionMixin:
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)
@@ -208,6 +237,9 @@ class _CanvasInteractionMixin:
# ---- 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
@@ -227,18 +259,25 @@ class _CanvasInteractionMixin:
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
+56
View File
@@ -58,6 +58,9 @@ 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
@@ -70,13 +73,22 @@ class _NodeItem(QGraphicsObject):
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))
@@ -137,10 +149,21 @@ class _NodeItem(QGraphicsObject):
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())
@@ -154,11 +177,17 @@ class _NodeItem(QGraphicsObject):
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()
@@ -172,6 +201,7 @@ class _NodeItem(QGraphicsObject):
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()
@@ -179,6 +209,7 @@ class _NodeItem(QGraphicsObject):
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()))
@@ -187,10 +218,12 @@ class _NodeItem(QGraphicsObject):
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")
@@ -205,11 +238,22 @@ class _NodeItem(QGraphicsObject):
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
@@ -223,6 +267,7 @@ class _EdgeItem(QGraphicsPathItem):
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
@@ -233,13 +278,20 @@ class _EdgeItem(QGraphicsPathItem):
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()
@@ -247,18 +299,21 @@ class _EdgeItem(QGraphicsPathItem):
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
@@ -277,6 +332,7 @@ class _EdgeItem(QGraphicsPathItem):
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:
+10
View File
@@ -11,9 +11,12 @@ 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:
@@ -23,6 +26,7 @@ class Co4EAgentsMixin:
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)
@@ -30,6 +34,7 @@ class Co4EAgentsMixin:
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:
@@ -38,11 +43,16 @@ class Co4EAgentsMixin:
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)
+67
View File
@@ -44,6 +44,16 @@ __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)
@@ -51,6 +61,7 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
_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)
@@ -73,6 +84,12 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
# ---- 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()
@@ -89,14 +106,22 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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
@@ -123,13 +148,24 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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:
@@ -137,6 +173,7 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
# ---- 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()
@@ -146,11 +183,17 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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)
@@ -162,12 +205,18 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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):
@@ -178,11 +227,15 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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)
@@ -190,6 +243,7 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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
@@ -202,23 +256,31 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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()
@@ -236,6 +298,11 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
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)
+27
View File
@@ -20,6 +20,11 @@ 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
@@ -95,6 +100,7 @@ class Co4EChatMixin:
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
@@ -104,11 +110,18 @@ class Co4EChatMixin:
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
@@ -179,6 +192,10 @@ class Co4EChatMixin:
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
@@ -186,6 +203,7 @@ class Co4EChatMixin:
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:
@@ -195,6 +213,10 @@ class Co4EChatMixin:
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
@@ -209,6 +231,7 @@ class Co4EChatMixin:
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
@@ -220,6 +243,7 @@ class Co4EChatMixin:
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")
@@ -240,6 +264,7 @@ class Co4EChatMixin:
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"])
@@ -248,6 +273,7 @@ class Co4EChatMixin:
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"]
@@ -256,6 +282,7 @@ class Co4EChatMixin:
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)
+15
View File
@@ -55,6 +55,7 @@ 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
@@ -62,6 +63,7 @@ def _skill_names() -> List[str]:
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
@@ -89,6 +91,7 @@ class _ChatInput(QLineEdit):
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
@@ -99,6 +102,7 @@ class _ChatInput(QLineEdit):
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()
@@ -126,12 +130,14 @@ class _ChatInput(QLineEdit):
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:
@@ -147,11 +153,15 @@ class _ChatInput(QLineEdit):
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()
@@ -199,6 +209,11 @@ class ChatPanel(QWidget):
"""
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)
+14
View File
@@ -15,6 +15,11 @@ 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,
@@ -76,6 +81,11 @@ class Co4EFlowTabsMixin:
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
@@ -111,11 +121,13 @@ class Co4EFlowTabsMixin:
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
@@ -145,6 +157,7 @@ class Co4EFlowTabsMixin:
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"))
@@ -176,5 +189,6 @@ class Co4EFlowTabsMixin:
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"))
+5
View File
@@ -22,7 +22,11 @@ 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)
@@ -235,6 +239,7 @@ class Co4ELayoutMixin:
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:
@@ -56,6 +56,11 @@ class RunsPagePanel(QWidget):
"""
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()
+30
View File
@@ -20,10 +20,19 @@ 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
@@ -31,6 +40,11 @@ class Co4ERunsMixin:
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)
@@ -42,6 +56,12 @@ class Co4ERunsMixin:
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"))
@@ -65,11 +85,13 @@ class Co4ERunsMixin:
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)
@@ -82,6 +104,7 @@ class Co4ERunsMixin:
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:
@@ -96,6 +119,7 @@ class Co4ERunsMixin:
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"))
@@ -113,6 +137,7 @@ class Co4ERunsMixin:
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)
@@ -122,6 +147,7 @@ class Co4ERunsMixin:
# 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
@@ -199,6 +225,7 @@ class Co4ERunsMixin:
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()
@@ -241,6 +268,7 @@ class Co4ERunsMixin:
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:
@@ -262,6 +290,7 @@ class Co4ERunsMixin:
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:
@@ -321,6 +350,7 @@ class Co4ERunsMixin:
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"))
+19
View File
@@ -20,6 +20,7 @@ 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
@@ -28,6 +29,14 @@ class Co4ESidebarMixin:
# 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)
@@ -42,9 +51,15 @@ class Co4ESidebarMixin:
"""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)
@@ -203,14 +218,17 @@ class Co4ESidebarMixin:
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")
@@ -246,6 +264,7 @@ class Co4ESidebarMixin:
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
+11
View File
@@ -36,6 +36,17 @@ mà mọi bước tách widget trước đó đã cố tình né. Trước khi
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
+32
View File
@@ -16,7 +16,18 @@ 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).
@@ -31,6 +42,13 @@ class Co4EWorkflowCrudMixin:
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
@@ -47,16 +65,19 @@ class Co4EWorkflowCrudMixin:
_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"))
@@ -65,6 +86,7 @@ class Co4EWorkflowCrudMixin:
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:
@@ -106,6 +128,7 @@ class Co4EWorkflowCrudMixin:
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
@@ -113,26 +136,34 @@ class Co4EWorkflowCrudMixin:
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())
@@ -140,6 +171,7 @@ class Co4EWorkflowCrudMixin:
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()
@@ -64,6 +64,7 @@ class _StepConfigActionsMixin:
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
@@ -102,6 +103,7 @@ class _StepConfigActionsMixin:
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()
@@ -111,6 +113,7 @@ class _StepConfigActionsMixin:
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
@@ -127,6 +130,7 @@ class _StepConfigActionsMixin:
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()
@@ -156,11 +160,13 @@ class _StepConfigActionsMixin:
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
@@ -172,6 +178,7 @@ class _StepConfigActionsMixin:
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
@@ -181,9 +188,11 @@ class _StepConfigActionsMixin:
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():
+15
View File
@@ -46,12 +46,20 @@ 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
@@ -229,6 +237,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# ---- 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
@@ -269,12 +282,14 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
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
+4
View File
@@ -36,12 +36,16 @@ class _PaletteList(QListWidget):
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
+5
View File
@@ -44,6 +44,11 @@ class SkillsListPanel(QWidget):
"""
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"))
+12
View File
@@ -40,6 +40,7 @@ class _SectionHeader(QLabel):
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)
@@ -52,6 +53,13 @@ class _SectionHeader(QLabel):
# _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)
@@ -105,6 +113,9 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
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
@@ -116,6 +127,7 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
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}")