## 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:
@@ -0,0 +1 @@
|
||||
"""Presentation Co4E package: Co4ECanvasWidget, NodePropertyPanel, RunControlWidget, Co4EChatView."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user