merge: lấy phần N3 của Lâm (6 widget UI Co4E) về nhánh chung
Không xung đột — Lâm động vào ui/co4e_tab.py và presentation/co4e/, tôi động vào ui/settings_dialog.py và presentation/settings/. Đúng như quy tắc phân chia sở hữu đặt ra từ đầu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+22
-775
@@ -12,780 +12,27 @@ Kept UI-only; the graph model lives in ``core/co4e.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
|
||||
from PySide6.QtWidgets import (
|
||||
QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QGraphicsScene,
|
||||
QGraphicsView, QMenu,
|
||||
# _NodeItem/_EdgeItem (hằng số vẽ + hai lớp QGraphicsItem) đã dời sang
|
||||
# presentation/co4e/canvas_items.py; Co4ECanvas (mutation đồ thị) đã dời sang
|
||||
# presentation/co4e/co4e_canvas_widget.py, phần tương tác view (zoom/pan/
|
||||
# relayout/phím tắt/kéo-thả) nằm trong _CanvasInteractionMixin cùng thư mục.
|
||||
# Không đổi hành vi — xem characterization test cùng tên và docstring ở từng
|
||||
# file đích. Import ĐÍCH DANH tên gốc, không alias — nếu đổi thành
|
||||
# `import co4e_canvas_widget as _w` thì các chỗ gọi bên dưới (và cả test cũ)
|
||||
# vẫn chạy được vì Python cho phép, nhưng lại sai mục đích của việc giữ nguyên
|
||||
# tên: test characterization import trực tiếp các tên này TỪ module này,
|
||||
# alias sẽ làm test đó ngưng chứng minh được điều nó cần chứng minh.
|
||||
from ..presentation.co4e.canvas_items import (
|
||||
_NODE_H, _NODE_W, _PORT_HIT, _PORT_R, _EdgeItem, _NodeItem, _status_color,
|
||||
)
|
||||
|
||||
from ..core.co4e import (
|
||||
STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step,
|
||||
compute_waves, new_edge_id, new_node_id,
|
||||
# 8 hàm hình học thuần đã dời sang canvas_geometry.py (không đổi hành vi, xem
|
||||
# characterization test cùng tên). Import ĐÍCH DANH tên gốc, không alias — nếu
|
||||
# đổi thành `import canvas_geometry as _g` thì các chỗ gọi bên dưới (và cả
|
||||
# test cũ) vẫn chạy được vì Python cho phép, nhưng lại sai mục đích của việc
|
||||
# giữ nguyên tên: test characterization import trực tiếp các tên này TỪ module
|
||||
# này, alias sẽ làm test đó ngưng chứng minh được điều nó cần chứng minh.
|
||||
from ..presentation.co4e.canvas_geometry import (
|
||||
_dist, _elide, _hits, _ortho_path, _route, _rounded_path, _seg_hits_rect,
|
||||
_towards,
|
||||
)
|
||||
from ..theme import current_palette
|
||||
|
||||
|
||||
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
|
||||
_CORNER_R = 12 # edge elbow corner radius
|
||||
|
||||
|
||||
class _NodeItem(QGraphicsObject):
|
||||
"""One draggable step card. Emits signals via the parent canvas."""
|
||||
|
||||
def __init__(self, node: Node, canvas: "Co4ECanvas"):
|
||||
super().__init__()
|
||||
self.node = node
|
||||
self.canvas = canvas
|
||||
self.status = "idle"
|
||||
self._porting = False
|
||||
self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable
|
||||
| QGraphicsItem.ItemSendsGeometryChanges)
|
||||
self.setAcceptHoverEvents(True)
|
||||
self.setPos(node.x, node.y)
|
||||
self.setZValue(2)
|
||||
|
||||
def boundingRect(self) -> QRectF:
|
||||
# slack left/right so the input/output ports (now on the sides) paint cleanly
|
||||
return QRectF(-_PORT_R - 2, -3, _NODE_W + 2 * _PORT_R + 4, _NODE_H + 6)
|
||||
|
||||
def _card_rect(self) -> QRectF:
|
||||
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
|
||||
|
||||
def paint(self, p, _opt, _widget=None):
|
||||
tok = current_palette()
|
||||
step = self.node.data
|
||||
accent = QColor(_status_color(self.status))
|
||||
body = QColor(tok.surface_raised)
|
||||
border = QColor(tok.accent) if self.isSelected() else QColor(tok.border)
|
||||
p.setRenderHint(p.RenderHint.Antialiasing)
|
||||
rect = self._card_rect()
|
||||
path = QPainterPath()
|
||||
radius = float(tok.radius_lg)
|
||||
path.addRoundedRect(rect, radius, radius)
|
||||
p.fillPath(path, QBrush(body))
|
||||
p.setPen(QPen(border, 2 if self.isSelected() else 1))
|
||||
p.drawPath(path)
|
||||
# header stripe — a tint of the status colour, not the status colour
|
||||
# itself, so the card's own text stays the brightest thing on it.
|
||||
hdr = QRectF(rect.left(), rect.top(), rect.width(), 26)
|
||||
hpath = QPainterPath()
|
||||
hpath.addRoundedRect(hdr, radius, radius)
|
||||
stripe = QColor(accent)
|
||||
stripe.setAlpha(48)
|
||||
p.fillPath(hpath, QBrush(stripe))
|
||||
# label
|
||||
p.setPen(QColor(tok.text))
|
||||
f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f)
|
||||
p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft,
|
||||
_elide(step.label, 26))
|
||||
# role badge + status
|
||||
f.setBold(False); f.setPointSize(8); p.setFont(f)
|
||||
p.setPen(accent)
|
||||
p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role)
|
||||
# body: instructions preview OR sub-agent chips
|
||||
p.setPen(QColor(tok.text_muted))
|
||||
if step.is_parallel:
|
||||
preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)"
|
||||
else:
|
||||
preview = step.instructions or "(no instructions)"
|
||||
p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop,
|
||||
_elide(preview, 66))
|
||||
# footer: model + skills + status dot
|
||||
p.setPen(QColor(tok.text_faint))
|
||||
foot = []
|
||||
if step.model:
|
||||
foot.append(step.model)
|
||||
if step.skills:
|
||||
foot.append(f"skills:{len(step.skills)}")
|
||||
foot.append(self.status)
|
||||
p.drawText(QRectF(10, _NODE_H - 18, _NODE_W - 20, 14), Qt.AlignLeft,
|
||||
_elide(" · ".join(foot), 34))
|
||||
# ---- ports ---------------------------------------------------------
|
||||
# input port (top-center): hollow. output port (bottom-center): filled —
|
||||
# the drag handle you pull to wire an edge to another step.
|
||||
port_col = QColor(tok.accent)
|
||||
# input port (left-center): hollow. output port (right-center): filled —
|
||||
# the drag handle you pull to wire an edge to the next step (left→right).
|
||||
p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4))
|
||||
p.drawEllipse(QPointF(1, _NODE_H / 2), _PORT_R - 1, _PORT_R - 1)
|
||||
p.setBrush(QBrush(port_col)); p.setPen(QPen(port_col, 1.4))
|
||||
p.drawEllipse(QPointF(_NODE_W - 1, _NODE_H / 2), _PORT_R, _PORT_R)
|
||||
|
||||
def _in_out_port(self, pos: QPointF) -> bool:
|
||||
d = pos - QPointF(_NODE_W, _NODE_H / 2)
|
||||
return (d.x() * d.x() + d.y() * d.y()) ** 0.5 <= _PORT_HIT
|
||||
|
||||
def itemChange(self, change, value):
|
||||
if change == QGraphicsItem.ItemPositionHasChanged:
|
||||
self.node.x = float(self.pos().x())
|
||||
self.node.y = float(self.pos().y())
|
||||
self.canvas._reposition_edges()
|
||||
self.canvas.graph_changed.emit()
|
||||
elif change == QGraphicsItem.ItemSelectedHasChanged:
|
||||
# a selected/edited node comes to the front (above the edges at z=3)
|
||||
self.setZValue(4 if value else 2)
|
||||
if value:
|
||||
self.canvas.node_selected.emit(self.node.id)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def hoverMoveEvent(self, e):
|
||||
# a hand cursor over the output port hints it's draggable-to-connect
|
||||
self.setCursor(Qt.PointingHandCursor if self._in_out_port(e.pos()) else Qt.ArrowCursor)
|
||||
super().hoverMoveEvent(e)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if self.canvas._connect_from is not None:
|
||||
self.canvas._finish_connect(self.node.id)
|
||||
e.accept()
|
||||
return
|
||||
if e.button() == Qt.LeftButton and self._in_out_port(e.pos()):
|
||||
# start a manual drag-to-connect from this node's output port
|
||||
self._porting = True
|
||||
self.canvas.begin_port_drag(self.node.id, self.mapToScene(QPointF(_NODE_W, _NODE_H / 2)))
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self._porting:
|
||||
self.canvas.update_port_drag(self.mapToScene(e.pos()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if self._porting:
|
||||
self._porting = False
|
||||
self.canvas.finish_port_drag(self.mapToScene(e.pos()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def mouseDoubleClickEvent(self, e):
|
||||
self.canvas.node_activated.emit(self.node.id)
|
||||
e.accept()
|
||||
|
||||
def contextMenuEvent(self, e):
|
||||
menu = QMenu()
|
||||
a_add = menu.addAction("+ Add next step")
|
||||
a_conn = menu.addAction("→ Connect from here")
|
||||
a_del = menu.addAction("🗑 Delete step")
|
||||
chosen = menu.exec(e.screenPos())
|
||||
if chosen is a_add:
|
||||
self.canvas.add_step_below(self.node.id)
|
||||
elif chosen is a_conn:
|
||||
self.canvas.begin_connect(self.node.id)
|
||||
elif chosen is a_del:
|
||||
self.canvas.delete_node(self.node.id)
|
||||
e.accept()
|
||||
|
||||
def center(self) -> QPointF:
|
||||
return self.pos() + QPointF(_NODE_W / 2, _NODE_H / 2)
|
||||
|
||||
|
||||
def _dist(a: QPointF, b: QPointF) -> float:
|
||||
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
|
||||
|
||||
|
||||
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
|
||||
dist = _dist(a, b)
|
||||
if dist < 1e-6:
|
||||
return QPointF(a)
|
||||
t = d / dist
|
||||
return QPointF(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t)
|
||||
|
||||
|
||||
def _rounded_path(points, r: float = _CORNER_R) -> QPainterPath:
|
||||
"""Build a path through axis-aligned ``points`` with rounded corners at each
|
||||
bend ("vuông bo cong ở góc")."""
|
||||
if not points:
|
||||
return QPainterPath()
|
||||
path = QPainterPath(points[0])
|
||||
if len(points) == 1:
|
||||
return path
|
||||
for i in range(1, len(points) - 1):
|
||||
prev, cur, nxt = points[i - 1], points[i], points[i + 1]
|
||||
rr = min(r, _dist(prev, cur) / 2.0, _dist(cur, nxt) / 2.0)
|
||||
path.lineTo(_towards(cur, prev, rr))
|
||||
path.quadTo(cur, _towards(cur, nxt, rr))
|
||||
path.lineTo(points[-1])
|
||||
return path
|
||||
|
||||
|
||||
def _seg_hits_rect(p1: QPointF, p2: QPointF, rect: QRectF) -> bool:
|
||||
"""Axis-aligned segment vs rectangle overlap (all routed segments are H or V)."""
|
||||
x1, y1, x2, y2 = p1.x(), p1.y(), p2.x(), p2.y()
|
||||
if abs(y1 - y2) < 0.5: # horizontal
|
||||
if rect.top() <= y1 <= rect.bottom():
|
||||
lo, hi = sorted((x1, x2))
|
||||
return not (hi < rect.left() or lo > rect.right())
|
||||
return False
|
||||
if abs(x1 - x2) < 0.5: # vertical
|
||||
if rect.left() <= x1 <= rect.right():
|
||||
lo, hi = sorted((y1, y2))
|
||||
return not (hi < rect.top() or lo > rect.bottom())
|
||||
return False
|
||||
box = QRectF(QPointF(min(x1, x2), min(y1, y2)), QPointF(max(x1, x2), max(y1, y2)))
|
||||
return rect.intersects(box)
|
||||
|
||||
|
||||
def _hits(points, obstacles) -> bool:
|
||||
for i in range(len(points) - 1):
|
||||
for r in obstacles:
|
||||
if _seg_hits_rect(points[i], points[i + 1], r):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _route(src: QPointF, dst: QPointF, obstacles=None):
|
||||
"""Waypoints for a LEFT→RIGHT orthogonal edge from ``src`` (a node's right
|
||||
output) to ``dst`` (the next node's left input) that AVOIDS the other node
|
||||
rectangles: try the straight elbow, then a clear vertical band, then a
|
||||
top/bottom detour — so a connector never overlaps or hides behind a step."""
|
||||
obstacles = list(obstacles or [])
|
||||
if abs(src.y() - dst.y()) < 1.5:
|
||||
cand = [src, dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
mid_x = (src.x() + dst.x()) / 2.0
|
||||
base = [src, QPointF(mid_x, src.y()), QPointF(mid_x, dst.y()), dst]
|
||||
if not _hits(base, obstacles):
|
||||
return base
|
||||
# 1) slide the vertical run to a clear band between the two columns
|
||||
lo, hi = min(src.x(), dst.x()) + 6, max(src.x(), dst.x()) - 6
|
||||
if hi > lo:
|
||||
for frac in (0.5, 0.35, 0.65, 0.2, 0.8):
|
||||
x = lo + (hi - lo) * frac
|
||||
cand = [src, QPointF(x, src.y()), QPointF(x, dst.y()), dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
# 2) detour above/below every obstacle, then back in
|
||||
margin = 44.0
|
||||
ys = [src.y(), dst.y()] + [r.top() for r in obstacles] + [r.bottom() for r in obstacles]
|
||||
out_x, in_x = src.x() + 34, dst.x() - 34 # short stubs out of the side ports
|
||||
for side_y in (min(ys) - margin, max(ys) + margin):
|
||||
cand = [src, QPointF(out_x, src.y()), QPointF(out_x, side_y),
|
||||
QPointF(in_x, side_y), QPointF(in_x, dst.y()), dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
return base
|
||||
|
||||
|
||||
def _ortho_path(src: QPointF, dst: QPointF, r: float = _CORNER_R) -> QPainterPath:
|
||||
"""Rounded orthogonal elbow (no obstacle avoidance) — used for the transient
|
||||
drag-to-connect line and by callers that pass no obstacles."""
|
||||
return _rounded_path(_route(src, dst), r)
|
||||
|
||||
|
||||
class _EdgeItem(QGraphicsPathItem):
|
||||
def __init__(self, edge: Edge, canvas: "Co4ECanvas"):
|
||||
super().__init__()
|
||||
self.edge = edge
|
||||
self.canvas = canvas
|
||||
self._dst: Optional[QPointF] = None
|
||||
# Above node cards (z=2) so a connecting line is never hidden behind a
|
||||
# step; a selected node bumps itself to the front while being edited.
|
||||
self.setZValue(3)
|
||||
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
||||
self.setAcceptHoverEvents(True)
|
||||
self._hover = False
|
||||
self._apply_pen()
|
||||
|
||||
def _apply_pen(self):
|
||||
tok = current_palette()
|
||||
if self.isSelected():
|
||||
color, w = QColor(tok.accent), 3
|
||||
elif self._hover:
|
||||
color, w = QColor(tok.text_muted), 3
|
||||
else:
|
||||
color, w = QColor(tok.border_strong), 2
|
||||
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
|
||||
|
||||
def update_path(self, points):
|
||||
self._dst = points[-1] if points else None
|
||||
self.setPath(_rounded_path(points))
|
||||
|
||||
def boundingRect(self):
|
||||
return super().boundingRect().adjusted(-10, -10, 10, 10) # room for the arrowhead
|
||||
|
||||
def shape(self):
|
||||
# Widen the clickable/selectable area so a thin line is easy to grab.
|
||||
from PySide6.QtGui import QPainterPathStroker
|
||||
stroker = QPainterPathStroker()
|
||||
stroker.setWidth(14)
|
||||
return stroker.createStroke(self.path())
|
||||
|
||||
def hoverEnterEvent(self, e):
|
||||
self._hover = True
|
||||
self._apply_pen()
|
||||
self.update()
|
||||
super().hoverEnterEvent(e)
|
||||
|
||||
def hoverLeaveEvent(self, e):
|
||||
self._hover = False
|
||||
self._apply_pen()
|
||||
self.update()
|
||||
super().hoverLeaveEvent(e)
|
||||
|
||||
def paint(self, p, opt, widget=None):
|
||||
self._apply_pen()
|
||||
super().paint(p, opt, widget)
|
||||
# arrowhead at the target, pointing right into its (left) input port
|
||||
if self._dst is not None:
|
||||
p.setRenderHint(p.RenderHint.Antialiasing)
|
||||
tip = self._dst
|
||||
s = 7.0
|
||||
tri = QPolygonF([
|
||||
QPointF(tip.x() + 1, tip.y()),
|
||||
QPointF(tip.x() - s, tip.y() - s * 0.7),
|
||||
QPointF(tip.x() - s, tip.y() + s * 0.7),
|
||||
])
|
||||
col = self.pen().color()
|
||||
p.setBrush(QBrush(col))
|
||||
p.setPen(QPen(col, 1))
|
||||
p.drawPolygon(tri)
|
||||
|
||||
def contextMenuEvent(self, e):
|
||||
menu = QMenu()
|
||||
act_del = menu.addAction("🗑 Delete connection")
|
||||
if menu.exec(e.screenPos()) is act_del:
|
||||
self.canvas.delete_edge(self.edge)
|
||||
e.accept()
|
||||
|
||||
|
||||
def _elide(text: str, n: int) -> str:
|
||||
text = (text or "").replace("\n", " ")
|
||||
return text if len(text) <= n else text[: n - 1] + "…"
|
||||
|
||||
|
||||
class Co4ECanvas(QGraphicsView):
|
||||
node_selected = Signal(str) # a node was clicked (→ config panel)
|
||||
node_activated = Signal(str) # double-clicked
|
||||
graph_changed = Signal() # nodes/edges/positions changed (autosave)
|
||||
|
||||
_ZOOM_MIN, _ZOOM_MAX = 0.3, 3.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("co4eCanvas") # themed frame (see theme.py)
|
||||
self._scene = QGraphicsScene(self)
|
||||
self.setScene(self._scene)
|
||||
self.setRenderHint(self.renderHints().Antialiasing)
|
||||
self.setDragMode(QGraphicsView.RubberBandDrag)
|
||||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||||
self.setAcceptDrops(True)
|
||||
self._nodes: Dict[str, _NodeItem] = {}
|
||||
self._edges: list[_EdgeItem] = []
|
||||
self._connect_from: Optional[str] = None
|
||||
self._zoom = 1.0
|
||||
self._panning = False # middle-mouse drag-to-pan
|
||||
self._pan_start = None
|
||||
self._overlay = None # bottom-left zoom/fit controls (parented to viewport)
|
||||
# manual drag-to-connect state
|
||||
self._port_src: Optional[str] = None
|
||||
self._port_src_pt: Optional[QPointF] = None
|
||||
self._temp_edge: Optional[QGraphicsPathItem] = None
|
||||
|
||||
# ---- bottom-left overlay (zoom / fit) --------------------------------
|
||||
def add_overlay(self, widget) -> None:
|
||||
self._overlay = widget
|
||||
widget.setParent(self.viewport())
|
||||
widget.show()
|
||||
widget.raise_()
|
||||
self._place_overlay()
|
||||
|
||||
def _place_overlay(self) -> None:
|
||||
if self._overlay is not None:
|
||||
self._overlay.adjustSize()
|
||||
vp = self.viewport()
|
||||
self._overlay.move(12, vp.height() - self._overlay.height() - 12)
|
||||
self._overlay.raise_()
|
||||
|
||||
def resizeEvent(self, e): # noqa: N802
|
||||
super().resizeEvent(e)
|
||||
self._place_overlay()
|
||||
|
||||
def scrollContentsBy(self, dx, dy): # noqa: N802
|
||||
# QGraphicsView scrolls the viewport's child widgets along with the
|
||||
# scene, so panning/scrolling would drag the zoom overlay off-corner.
|
||||
# Re-pin it after every scroll so +/−/fit stay fixed in place.
|
||||
super().scrollContentsBy(dx, dy)
|
||||
self._place_overlay()
|
||||
|
||||
def showEvent(self, e): # noqa: N802
|
||||
super().showEvent(e)
|
||||
self._place_overlay() # viewport size is final once shown
|
||||
|
||||
# ---- load / serialize -------------------------------------------------
|
||||
def load(self, nodes, edges) -> None:
|
||||
self._scene.clear()
|
||||
self._nodes.clear()
|
||||
self._edges.clear()
|
||||
self._connect_from = None
|
||||
self._port_src = None
|
||||
self._temp_edge = None
|
||||
for n in nodes:
|
||||
item = _NodeItem(n, self)
|
||||
self._nodes[n.id] = item
|
||||
self._scene.addItem(item)
|
||||
for e in edges:
|
||||
if e.source in self._nodes and e.target in self._nodes:
|
||||
self._add_edge_item(e)
|
||||
self._reposition_edges()
|
||||
|
||||
def nodes(self):
|
||||
return [it.node for it in self._nodes.values()]
|
||||
|
||||
def edges(self):
|
||||
return [it.edge for it in self._edges]
|
||||
|
||||
# ---- mutation ---------------------------------------------------------
|
||||
def add_node(self, step: Step, x: float = 60.0, y: float = 60.0,
|
||||
connect_from: str = "") -> str:
|
||||
node = Node(id=new_node_id(), x=x, y=y, data=step)
|
||||
item = _NodeItem(node, self)
|
||||
self._nodes[node.id] = item
|
||||
self._scene.addItem(item)
|
||||
if connect_from and connect_from in self._nodes:
|
||||
self._make_edge(connect_from, node.id)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
self.node_selected.emit(node.id)
|
||||
return node.id
|
||||
|
||||
def add_step_below(self, node_id: str) -> None:
|
||||
"""Add the next step to the RIGHT of ``node_id`` (horizontal flow)."""
|
||||
parent = self._nodes.get(node_id)
|
||||
if parent is None:
|
||||
return
|
||||
step = Step(label="New Step")
|
||||
self.add_node(step, x=parent.node.x + _NODE_W + 150, y=parent.node.y, connect_from=node_id)
|
||||
|
||||
def _chain_tail(self) -> str:
|
||||
"""A node with no outgoing edge (so a freshly added node chains on)."""
|
||||
sources = {e.edge.source for e in self._edges}
|
||||
tails = [nid for nid in self._nodes if nid not in sources]
|
||||
return tails[-1] if tails else (next(reversed(self._nodes), "") if self._nodes else "")
|
||||
|
||||
def add_palette_step(self, step: Step, pos: QPointF) -> None:
|
||||
tail = self._chain_tail()
|
||||
self.add_node(step, x=pos.x(), y=pos.y(), connect_from=tail)
|
||||
|
||||
def begin_connect(self, source_id: str) -> None:
|
||||
self._connect_from = source_id
|
||||
|
||||
def _finish_connect(self, target_id: str) -> None:
|
||||
src = self._connect_from
|
||||
self._connect_from = None
|
||||
if src and src != target_id:
|
||||
self._make_edge(src, target_id)
|
||||
|
||||
# ---- manual drag-to-connect (from a node's output port) ---------------
|
||||
def begin_port_drag(self, source_id: str, scene_pt: QPointF) -> None:
|
||||
self._port_src = source_id
|
||||
self._port_src_pt = scene_pt
|
||||
self._temp_edge = QGraphicsPathItem()
|
||||
self._temp_edge.setZValue(3.5) # above nodes + edges while connecting
|
||||
self._temp_edge.setPen(
|
||||
QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap))
|
||||
self._scene.addItem(self._temp_edge)
|
||||
|
||||
def update_port_drag(self, scene_pt: QPointF) -> None:
|
||||
if self._temp_edge is None or self._port_src_pt is None:
|
||||
return
|
||||
self._temp_edge.setPath(_ortho_path(self._port_src_pt, scene_pt))
|
||||
|
||||
def finish_port_drag(self, scene_pt: QPointF) -> None:
|
||||
src = self._port_src
|
||||
if self._temp_edge is not None:
|
||||
self._scene.removeItem(self._temp_edge)
|
||||
self._temp_edge = None
|
||||
self._port_src = None
|
||||
self._port_src_pt = None
|
||||
tgt = self._node_at(scene_pt)
|
||||
if src and tgt and tgt != src:
|
||||
self._make_edge(src, tgt)
|
||||
|
||||
def _node_at(self, scene_pt: QPointF) -> Optional[str]:
|
||||
for it in self._scene.items(scene_pt):
|
||||
if isinstance(it, _NodeItem):
|
||||
return it.node.id
|
||||
return None
|
||||
|
||||
def _make_edge(self, source: str, target: str) -> None:
|
||||
if source == target:
|
||||
return
|
||||
if any(e.edge.source == source and e.edge.target == target for e in self._edges):
|
||||
return
|
||||
edge = Edge(id=new_edge_id(source, target), source=source, target=target)
|
||||
self._add_edge_item(edge)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def _add_edge_item(self, edge: Edge) -> None:
|
||||
item = _EdgeItem(edge, self)
|
||||
self._edges.append(item)
|
||||
self._scene.addItem(item)
|
||||
|
||||
def delete_edge(self, edge: Edge) -> None:
|
||||
for e in list(self._edges):
|
||||
if e.edge is edge or (e.edge.source == edge.source and e.edge.target == edge.target):
|
||||
self._scene.removeItem(e)
|
||||
self._edges.remove(e)
|
||||
self.graph_changed.emit()
|
||||
|
||||
def delete_node(self, node_id: str) -> None:
|
||||
item = self._nodes.pop(node_id, None)
|
||||
if item is None:
|
||||
return
|
||||
self._scene.removeItem(item)
|
||||
for e in list(self._edges):
|
||||
if e.edge.source == node_id or e.edge.target == node_id:
|
||||
self._scene.removeItem(e)
|
||||
self._edges.remove(e)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def delete_selected(self) -> None:
|
||||
for nid in [it.node.id for it in self._nodes.values() if it.isSelected()]:
|
||||
self.delete_node(nid)
|
||||
for e in [it.edge for it in self._edges if it.isSelected()]:
|
||||
self.delete_edge(e)
|
||||
|
||||
# ---- zoom / fit -------------------------------------------------------
|
||||
def _zoom_by(self, factor: float) -> None:
|
||||
# Derive the CURRENT scale from the live transform (never a separate
|
||||
# accumulator that can drift out of sync with fit_view/relayout/reset —
|
||||
# that drift is what made the +/− buttons and Ctrl+wheel randomly stop
|
||||
# working). Clamp the TARGET to the range and apply the exact factor to
|
||||
# reach it, so zooming still works right up to the limits.
|
||||
cur = self.transform().m11() or 1.0
|
||||
target = max(self._ZOOM_MIN, min(self._ZOOM_MAX, cur * factor))
|
||||
if abs(target - cur) < 1e-6:
|
||||
return
|
||||
self.scale(target / cur, target / cur)
|
||||
self._zoom = target
|
||||
|
||||
def zoom_in(self) -> None:
|
||||
self._zoom_by(1.15)
|
||||
|
||||
def zoom_out(self) -> None:
|
||||
self._zoom_by(1 / 1.15)
|
||||
|
||||
def reset_zoom(self) -> None:
|
||||
self.resetTransform()
|
||||
self._zoom = 1.0
|
||||
|
||||
def wheelEvent(self, e):
|
||||
# Ctrl+wheel = zoom (anchored under the cursor); Shift+wheel = pan
|
||||
# horizontally; plain wheel scrolls vertically.
|
||||
if e.modifiers() & Qt.ControlModifier:
|
||||
self._zoom_by(1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
||||
e.accept()
|
||||
return
|
||||
if e.modifiers() & Qt.ShiftModifier:
|
||||
bar = self.horizontalScrollBar()
|
||||
bar.setValue(bar.value() - e.angleDelta().y())
|
||||
e.accept()
|
||||
return
|
||||
super().wheelEvent(e)
|
||||
|
||||
# ---- middle-mouse drag-to-pan ----------------------------------------
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == Qt.MiddleButton:
|
||||
self._panning = True
|
||||
self._pan_start = e.position().toPoint()
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self._panning and self._pan_start is not None:
|
||||
pos = e.position().toPoint()
|
||||
delta = pos - self._pan_start
|
||||
self._pan_start = pos
|
||||
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x())
|
||||
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y())
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if e.button() == Qt.MiddleButton and self._panning:
|
||||
self._panning = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def fit_view(self) -> None:
|
||||
"""Auto-fit: zoom/pan so every node is visible with a small margin."""
|
||||
rect = self._scene.itemsBoundingRect()
|
||||
if rect.isNull():
|
||||
return
|
||||
self.setSceneRect(rect.adjusted(-60, -60, 60, 60))
|
||||
self.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||||
# keep the zoom accumulator in sync with the transform fitInView applied
|
||||
self._zoom = self.transform().m11() or 1.0
|
||||
|
||||
def relayout(self, hgap: float = 110.0, vgap: float = 40.0) -> None:
|
||||
"""Arrange nodes LEFT→RIGHT by dependency depth: each topological wave is
|
||||
a column (x = wave), siblings stacked vertically within it. Used to turn
|
||||
an old top-down graph into the horizontal flow layout."""
|
||||
nodes = [it.node for it in self._nodes.values()]
|
||||
edges = [it.edge for it in self._edges]
|
||||
if not nodes:
|
||||
return
|
||||
waves = compute_waves(nodes, edges)
|
||||
from collections import defaultdict
|
||||
cols: Dict[int, list] = defaultdict(list)
|
||||
for n in nodes:
|
||||
cols[waves.get(n.id, 0)].append(n)
|
||||
for w in sorted(cols):
|
||||
for row, n in enumerate(sorted(cols[w], key=lambda nn: (nn.y, nn.x))):
|
||||
item = self._nodes.get(n.id)
|
||||
if item is not None:
|
||||
item.setPos(w * (_NODE_W + hgap), row * (_NODE_H + vgap))
|
||||
self._reposition_edges()
|
||||
|
||||
def relayout_if_vertical(self) -> None:
|
||||
"""Convert a graph that's stacked vertically (the old top-down layout, or
|
||||
overlapping nodes) into the horizontal left→right layout — but leave a
|
||||
graph the user already arranged horizontally untouched."""
|
||||
nodes = [it.node for it in self._nodes.values()]
|
||||
if len(nodes) < 2:
|
||||
return
|
||||
xs = [n.x for n in nodes]
|
||||
if max(xs) - min(xs) < _NODE_W: # all in one column → it's vertical
|
||||
self.relayout()
|
||||
|
||||
def add_workflow(self, nodes, edges, at: Optional[QPointF] = None) -> None:
|
||||
"""Drop/merge a whole flow's nodes+edges onto the canvas with fresh ids
|
||||
(so the same template can be dropped several times). Offsets it near
|
||||
``at`` when given, else tiles it beside whatever is already there."""
|
||||
remap: Dict[str, str] = {}
|
||||
# offset so a dropped template doesn't land exactly on existing nodes
|
||||
ox = (at.x() - nodes[0].x) if (at and nodes) else (60 if self._nodes else 0)
|
||||
oy = (at.y() - nodes[0].y) if (at and nodes) else (60 if self._nodes else 0)
|
||||
for n in nodes:
|
||||
new = Node(id=new_node_id(), x=n.x + ox, y=n.y + oy, data=copy.deepcopy(n.data))
|
||||
remap[n.id] = new.id
|
||||
item = _NodeItem(new, self)
|
||||
self._nodes[new.id] = item
|
||||
self._scene.addItem(item)
|
||||
for e in edges:
|
||||
s, t = remap.get(e.source), remap.get(e.target)
|
||||
if s and t:
|
||||
self._add_edge_item(Edge(id=new_edge_id(s, t), source=s, target=t))
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def update_node_status(self, node_id: str, status: str) -> None:
|
||||
item = self._nodes.get(node_id)
|
||||
if item is not None:
|
||||
item.status = status
|
||||
item.update()
|
||||
|
||||
def reset_statuses(self) -> None:
|
||||
for it in self._nodes.values():
|
||||
it.status = "idle"
|
||||
it.update()
|
||||
|
||||
def refresh_node(self, node_id: str) -> None:
|
||||
item = self._nodes.get(node_id)
|
||||
if item is not None:
|
||||
item.update()
|
||||
|
||||
def _node_rects(self, exclude):
|
||||
"""Rectangles of every node except ``exclude`` (inflated a little), used
|
||||
as obstacles the edge router steers around."""
|
||||
m = 12.0
|
||||
out = []
|
||||
for nid, item in self._nodes.items():
|
||||
if nid in exclude:
|
||||
continue
|
||||
p = item.pos()
|
||||
out.append(QRectF(p.x(), p.y(), _NODE_W, _NODE_H).adjusted(-m, -m, m, m))
|
||||
return out
|
||||
|
||||
def _reposition_edges(self) -> None:
|
||||
for e in self._edges:
|
||||
s = self._nodes.get(e.edge.source)
|
||||
t = self._nodes.get(e.edge.target)
|
||||
if s is None or t is None:
|
||||
continue
|
||||
src = s.pos() + QPointF(_NODE_W, _NODE_H / 2) # right-center (output)
|
||||
dst = t.pos() + QPointF(0, _NODE_H / 2) # left-center (input)
|
||||
obstacles = self._node_rects({e.edge.source, e.edge.target})
|
||||
e.update_path(_route(src, dst, obstacles))
|
||||
|
||||
# ---- key / drop -------------------------------------------------------
|
||||
def keyPressEvent(self, e):
|
||||
if e.key() in (Qt.Key_Delete, Qt.Key_Backspace):
|
||||
self.delete_selected()
|
||||
return
|
||||
if e.key() == Qt.Key_Escape:
|
||||
self._connect_from = None
|
||||
if self._temp_edge is not None:
|
||||
self._scene.removeItem(self._temp_edge)
|
||||
self._temp_edge = None
|
||||
self._port_src = None
|
||||
return
|
||||
if e.key() in (Qt.Key_Plus, Qt.Key_Equal) and (e.modifiers() & Qt.ControlModifier):
|
||||
self.zoom_in(); return
|
||||
if e.key() == Qt.Key_Minus and (e.modifiers() & Qt.ControlModifier):
|
||||
self.zoom_out(); return
|
||||
if e.key() == Qt.Key_0 and (e.modifiers() & Qt.ControlModifier):
|
||||
self.reset_zoom(); return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
def dragEnterEvent(self, e):
|
||||
if e.mimeData().hasFormat(CO4E_MIME):
|
||||
e.acceptProposedAction()
|
||||
else:
|
||||
super().dragEnterEvent(e)
|
||||
|
||||
def dragMoveEvent(self, e):
|
||||
if e.mimeData().hasFormat(CO4E_MIME):
|
||||
e.acceptProposedAction()
|
||||
else:
|
||||
super().dragMoveEvent(e)
|
||||
|
||||
def dropEvent(self, e):
|
||||
if not e.mimeData().hasFormat(CO4E_MIME):
|
||||
super().dropEvent(e)
|
||||
return
|
||||
try:
|
||||
payload = json.loads(bytes(e.mimeData().data(CO4E_MIME)).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return
|
||||
pos = self.mapToScene(e.position().toPoint())
|
||||
if isinstance(payload, dict) and payload.get("kind") == "workflow":
|
||||
# A whole flow dragged from the sidebar → merge its graph in.
|
||||
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()
|
||||
from ..presentation.co4e.co4e_canvas_widget import Co4ECanvas, CO4E_MIME
|
||||
|
||||
+8
-522
@@ -1,528 +1,14 @@
|
||||
"""Co4E right-hand config panels — edit a selected step node's persona.
|
||||
|
||||
StepConfigPanel edits the fields of a ``core.co4e.Step`` in place and emits
|
||||
``changed`` (so the canvas repaints + the workflow autosaves) and ``run_node`` /
|
||||
``delete_node`` for the footer actions. Kept intentionally close to nova's
|
||||
config-panel.tsx field set: label, role, icon, instructions, model, permission
|
||||
preset, self-verify (+rounds), attached skills, and — for parallel nodes — the
|
||||
sub-agent list.
|
||||
StepConfigPanel has moved to ``presentation/co4e/node_property_panel.py``
|
||||
(split further into ``presentation/co4e/step_config_section.py`` and
|
||||
``presentation/co4e/node_property_actions_mixin.py`` to stay under the
|
||||
400-line-per-file cap). Re-exported here, unchanged in name and behaviour, so
|
||||
every existing ``from .co4e_config_panel import StepConfigPanel`` (e.g.
|
||||
``ui/co4e_tab.py``) keeps working without edits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from ..presentation.co4e.node_property_panel import StepConfigPanel
|
||||
|
||||
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit,
|
||||
QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
from ..core.co4e import PERMISSION_PRESETS, Step, SubAgent
|
||||
from ..i18n import tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon, icon_picker_combo
|
||||
|
||||
_SECTION_ANIM_MS = 180
|
||||
|
||||
|
||||
class _SectionHeader(QLabel):
|
||||
"""A clickable label — a QPushButton's own style chrome (border, native
|
||||
button margin, focus rect) always leaves a taller minimum height than a
|
||||
plain label, even once its QSS padding is zeroed out, so the header that
|
||||
needs to sit tight against its neighbours is a label, not a button."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802
|
||||
# fontMetrics() at construction time (before this label is ever part
|
||||
# of a shown top-level window) reflects the QSS font-size only if the
|
||||
# style has fully polished by then — on the very FIRST paint of the
|
||||
# Co4E screen it sometimes hasn't, so the fixed height computed in
|
||||
# _add_section is briefly wrong (too tall) until something else
|
||||
# triggers a relayout. Recomputing here, every time the label
|
||||
# actually becomes visible, means the first paint is never stale.
|
||||
self.setFixedHeight(self.fontMetrics().height())
|
||||
super().showEvent(event)
|
||||
|
||||
|
||||
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
"""One group of fields, collapsed to just its heading by default and
|
||||
independently expandable, so a long step config reads as a short list of
|
||||
group names until you open the one you need. Deliberately bare — no card
|
||||
border/background/box — the ▶/▼ marker and the heading text are the only
|
||||
things separating one group from the next; opening one never closes
|
||||
another (not an accordion, not a tab bar). Returns ``(form, card)``: add
|
||||
the group's rows to ``form``; ``card`` is the whole section (header +
|
||||
body) — hide it to remove the group entirely (e.g. for a section that
|
||||
only applies to some steps), rather than hiding individual rows inside
|
||||
an always-visible header."""
|
||||
p = current_palette()
|
||||
card = QWidget()
|
||||
card_lay = QVBoxLayout(card)
|
||||
card_lay.setContentsMargins(0, 0, 0, 0)
|
||||
card_lay.setSpacing(0)
|
||||
|
||||
header = _SectionHeader()
|
||||
header.setCursor(Qt.PointingHandCursor)
|
||||
header.setStyleSheet(f"font-weight:400; font-size:13px; color:{p.text}; padding:0; margin:0;")
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
# QSS font-size only lands on the widget's actual QFont (and therefore
|
||||
# its fontMetrics()) once the style sheet is polished — ensurePolished()
|
||||
# forces that now, so the fixed height below is computed from the 12px
|
||||
# font just set above, not the default one this label was constructed
|
||||
# with. A label's natural sizeHint still reserves font leading above/
|
||||
# below the glyphs on top of the (now zeroed) QSS padding — pinning the
|
||||
# height to the text's actual cap-to-baseline span is what closes that
|
||||
# last gap without clipping the ▶ glyph, the title, or Vietnamese
|
||||
# diacritics.
|
||||
header.ensurePolished()
|
||||
header.setFixedHeight(header.fontMetrics().height())
|
||||
header.setText(f"▶ {title}")
|
||||
card_lay.addWidget(header)
|
||||
|
||||
body = QWidget()
|
||||
body.setVisible(False)
|
||||
body.setMaximumHeight(0)
|
||||
form = QFormLayout(body)
|
||||
form.setContentsMargins(0, 6, 0, 0)
|
||||
card_lay.addWidget(body)
|
||||
|
||||
anim = QPropertyAnimation(body, b"maximumHeight", body)
|
||||
anim.setDuration(_SECTION_ANIM_MS)
|
||||
anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
is_open = False
|
||||
|
||||
def _on_finished() -> None:
|
||||
if is_open:
|
||||
# Uncapped once open, so switching to a step whose fields make
|
||||
# this section taller/shorter (e.g. a parallel node's sub-agent
|
||||
# list appearing) is never clipped by the height this animation
|
||||
# last landed on.
|
||||
body.setMaximumHeight(16_777_215)
|
||||
else:
|
||||
body.setVisible(False)
|
||||
anim.finished.connect(_on_finished)
|
||||
|
||||
def _toggle() -> None:
|
||||
nonlocal is_open
|
||||
is_open = not is_open
|
||||
header.setText(f"{'▼' if is_open else '▶'} {title}")
|
||||
anim.stop()
|
||||
if is_open:
|
||||
body.setVisible(True)
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(body.sizeHint().height())
|
||||
else:
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(0)
|
||||
anim.start()
|
||||
header.clicked.connect(_toggle)
|
||||
|
||||
outer.addWidget(card)
|
||||
return form, card
|
||||
|
||||
|
||||
class StepConfigPanel(QScrollArea):
|
||||
changed = Signal() # any field edited → repaint node + autosave
|
||||
run_node = Signal(str) # "Run this step" (node id)
|
||||
run_from = Signal(str) # "Run from here"
|
||||
delete_node = Signal(str) # "Delete step"
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._step: Optional[Step] = None
|
||||
self._node_id = ""
|
||||
self._loading = False
|
||||
self.setWidgetResizable(True)
|
||||
host = QWidget()
|
||||
self.setWidget(host)
|
||||
outer = QVBoxLayout(host)
|
||||
outer.setSpacing(1)
|
||||
|
||||
# Grouped sections stacked on one scrolling page — same fields as
|
||||
# before, grouped by what they're for: identity, execution
|
||||
# (model/permission), and the extra resources fed to the step
|
||||
# (skills/files/sub-agents). No tabs/accordion: every group's border
|
||||
# and heading are what separate it from its neighbours, and all three
|
||||
# are on screen (or one scroll away) at once.
|
||||
form, _basic_card = _add_section(outer, tr("co4e.tab_basic"))
|
||||
|
||||
self.label_edit = QLineEdit()
|
||||
self.label_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_label"), self.label_edit)
|
||||
|
||||
self.role_edit = QLineEdit()
|
||||
self.role_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_role"), self.role_edit)
|
||||
|
||||
# Dropdown of every icon in the registry (Monitoring's Icon Management
|
||||
# set + built-ins), each row previewing its actual glyph — still
|
||||
# editable so a not-yet-added custom name can be typed directly.
|
||||
self.icon_edit = icon_picker_combo()
|
||||
self.icon_edit.lineEdit().setPlaceholderText(tr("co4e.f_icon_placeholder"))
|
||||
self.icon_edit.currentTextChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_icon"), self.icon_edit)
|
||||
|
||||
self.instructions_edit = QPlainTextEdit()
|
||||
self.instructions_edit.setMaximumHeight(120)
|
||||
self.instructions_edit.textChanged.connect(self._on_edit)
|
||||
self.gen_btn = QPushButton(tr("co4e.ai_draft"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip"))
|
||||
self.gen_btn.setEnabled(ctx is not None)
|
||||
self.gen_btn.clicked.connect(self._ai_draft)
|
||||
instr_box = QWidget()
|
||||
ib = QVBoxLayout(instr_box)
|
||||
ib.setContentsMargins(0, 0, 0, 0)
|
||||
ib.addWidget(self.instructions_edit)
|
||||
ib.addWidget(self.gen_btn, alignment=Qt.AlignRight)
|
||||
form.addRow(tr("co4e.f_instructions"), instr_box)
|
||||
|
||||
# Extra context — free-text background/info fed to the step at run time
|
||||
# (in addition to instructions, attachments and upstream outputs).
|
||||
self.context_edit = QPlainTextEdit()
|
||||
self.context_edit.setMaximumHeight(90)
|
||||
self.context_edit.setPlaceholderText(tr("co4e.f_context_placeholder"))
|
||||
self.context_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_context"), self.context_edit)
|
||||
|
||||
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setEditable(True)
|
||||
self.model_combo.editTextChanged.connect(self._on_edit)
|
||||
self.load_models_btn = QPushButton()
|
||||
self.load_models_btn.setIcon(icon("download"))
|
||||
self.load_models_btn.setToolTip(tr("co4e.load_models_tooltip"))
|
||||
self.load_models_btn.clicked.connect(self._load_models)
|
||||
self.load_models_btn.setEnabled(ctx is not None)
|
||||
model_row.addWidget(self.model_combo, 1)
|
||||
model_row.addWidget(self.load_models_btn)
|
||||
mrow = QWidget(); mrow.setLayout(model_row)
|
||||
form2.addRow(tr("co4e.f_model"), mrow)
|
||||
|
||||
self.perm_combo = QComboBox()
|
||||
for preset in PERMISSION_PRESETS:
|
||||
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
|
||||
self.perm_combo.currentIndexChanged.connect(self._on_edit)
|
||||
form2.addRow(tr("co4e.f_permission"), self.perm_combo)
|
||||
|
||||
verify_row = QHBoxLayout()
|
||||
self.verify_chk = QCheckBox(tr("co4e.f_self_verify"))
|
||||
self.verify_chk.toggled.connect(self._on_edit)
|
||||
self.rounds_spin = QSpinBox()
|
||||
self.rounds_spin.setRange(1, 5)
|
||||
self.rounds_spin.valueChanged.connect(self._on_edit)
|
||||
verify_row.addWidget(self.verify_chk)
|
||||
verify_row.addWidget(QLabel(tr("co4e.f_verify_rounds")))
|
||||
verify_row.addWidget(self.rounds_spin)
|
||||
verify_row.addStretch(1)
|
||||
vrow = QWidget(); vrow.setLayout(verify_row)
|
||||
form2.addRow("", vrow)
|
||||
|
||||
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files"))
|
||||
|
||||
# Skills checklist (registry skills)
|
||||
self.skills_list = QListWidget()
|
||||
self.skills_list.setMaximumHeight(110)
|
||||
self.skills_list.itemChanged.connect(self._on_edit)
|
||||
form3.addRow(tr("co4e.f_skills"), self.skills_list)
|
||||
|
||||
# Attachments — files whose extracted text is fed to this step at run time.
|
||||
self.attach_list = QListWidget()
|
||||
self.attach_list.setMaximumHeight(80)
|
||||
self.attach_add_btn = QPushButton(tr("co4e.attach_add"))
|
||||
self.attach_add_btn.setIcon(icon("plus"))
|
||||
self.attach_add_btn.clicked.connect(self._add_attachment)
|
||||
self.attach_del_btn = QPushButton(tr("co4e.attach_remove"))
|
||||
self.attach_del_btn.setIcon(icon("trash"))
|
||||
self.attach_del_btn.clicked.connect(self._del_attachment)
|
||||
att_btns = QHBoxLayout()
|
||||
att_btns.addWidget(self.attach_add_btn)
|
||||
att_btns.addWidget(self.attach_del_btn)
|
||||
att_btns.addStretch(1)
|
||||
abtn = QWidget(); abtn.setLayout(att_btns)
|
||||
form3.addRow(tr("co4e.f_attachments"), self.attach_list)
|
||||
form3.addRow("", abtn)
|
||||
|
||||
# Parallel sub-agents get their OWN section — same header style as
|
||||
# Cơ bản/Model & Quyền/Skills & Tệp — rather than a row buried inside
|
||||
# Skills & Tệp, since it's really a distinct group, just one that
|
||||
# only applies to parallel-variant steps. load_step() hides the whole
|
||||
# card for a non-parallel step (see is_par below).
|
||||
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents"))
|
||||
self.sub_list = QListWidget()
|
||||
self.sub_list.setMaximumHeight(90)
|
||||
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
|
||||
self.sub_add_btn = QPushButton(tr("co4e.add_subagent"))
|
||||
self.sub_add_btn.setIcon(icon("plus"))
|
||||
self.sub_add_btn.clicked.connect(self._add_subagent)
|
||||
self.sub_del_btn = QPushButton(tr("co4e.del_subagent"))
|
||||
self.sub_del_btn.setIcon(icon("trash"))
|
||||
self.sub_del_btn.clicked.connect(self._del_subagent)
|
||||
sub_btns = QHBoxLayout()
|
||||
sub_btns.addWidget(self.sub_add_btn)
|
||||
sub_btns.addWidget(self.sub_del_btn)
|
||||
sub_btns.addStretch(1)
|
||||
sbtn = QWidget(); sbtn.setLayout(sub_btns)
|
||||
form4.addRow(self.sub_list)
|
||||
form4.addRow("", sbtn)
|
||||
|
||||
# Footer actions — one compact row (Run · Run from here · Delete),
|
||||
# kept below every section, not inside one of the cards.
|
||||
self.run_btn = QPushButton(tr("co4e.run"))
|
||||
self.run_btn.setIcon(icon("play"))
|
||||
self.run_btn.setToolTip(tr("co4e.run_this_step"))
|
||||
self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id))
|
||||
self.run_from_btn = QPushButton(tr("co4e.run_from_here"))
|
||||
self.run_from_btn.setToolTip(tr("co4e.run_from_here"))
|
||||
self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id))
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setObjectName("danger")
|
||||
self.del_btn.setToolTip(tr("co4e.delete_step"))
|
||||
self.del_btn.setFixedWidth(38)
|
||||
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
|
||||
foot = QHBoxLayout()
|
||||
foot.addWidget(self.run_btn, 1)
|
||||
foot.addWidget(self.run_from_btn, 1)
|
||||
foot.addWidget(self.del_btn)
|
||||
foot_w = QWidget(); foot_w.setLayout(foot)
|
||||
outer.addWidget(foot_w)
|
||||
# Without this, QVBoxLayout hands every child widget an EQUAL share of
|
||||
# whatever extra height the scroll area's viewport has beyond the
|
||||
# content's own sizeHint (setWidgetResizable(True) stretches `host` to
|
||||
# fill it) — each collapsed header's card was measuring a true
|
||||
# sizeHint of ~17px but rendering over 100px taller, and no amount of
|
||||
# margin/padding/spacing on the header itself could touch that: the
|
||||
# surplus was being spent on the cards, not around them. One trailing
|
||||
# stretch absorbs all of it instead, so every section (and the
|
||||
# footer) renders at exactly its own natural height.
|
||||
outer.addStretch(1)
|
||||
|
||||
self.setEnabled(False)
|
||||
|
||||
# ---- load a step ------------------------------------------------------
|
||||
def load_step(self, node_id: str, step: Step, skill_names: List[str]) -> None:
|
||||
self._loading = True
|
||||
self._node_id = node_id
|
||||
self._step = step
|
||||
self.setEnabled(True)
|
||||
self.label_edit.setText(step.label)
|
||||
self.role_edit.setText(step.role)
|
||||
self.icon_edit.setCurrentText(step.icon)
|
||||
self.instructions_edit.setPlainText(step.instructions)
|
||||
self.context_edit.setPlainText(getattr(step, "context", ""))
|
||||
self.model_combo.setEditText(step.model)
|
||||
idx = self.perm_combo.findData(step.permission_preset)
|
||||
self.perm_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.verify_chk.setChecked(step.self_verify)
|
||||
self.rounds_spin.setValue(max(1, step.max_verify_rounds))
|
||||
# skills checklist
|
||||
self.skills_list.clear()
|
||||
for name in skill_names:
|
||||
it = QListWidgetItem(name)
|
||||
it.setFlags(it.flags() | Qt.ItemIsUserCheckable)
|
||||
it.setCheckState(Qt.Checked if name in step.skills else Qt.Unchecked)
|
||||
self.skills_list.addItem(it)
|
||||
# attachments
|
||||
self.attach_list.clear()
|
||||
from pathlib import Path as _P
|
||||
for path in step.attachments:
|
||||
item = QListWidgetItem(_P(path).name)
|
||||
item.setToolTip(path)
|
||||
self.attach_list.addItem(item)
|
||||
# parallel sub-agents — the whole "Agent song song" section only
|
||||
# applies to parallel-variant steps, so the entire card (header
|
||||
# included) is hidden for any other step, not just its rows.
|
||||
is_par = step.is_parallel
|
||||
self._parallel_card.setVisible(is_par)
|
||||
self.sub_list.clear()
|
||||
if is_par:
|
||||
for sub in step.sub_agents:
|
||||
self.sub_list.addItem(sub.agent)
|
||||
self._loading = False
|
||||
|
||||
def clear_step(self) -> None:
|
||||
self._step = None
|
||||
self._node_id = ""
|
||||
self.setEnabled(False)
|
||||
|
||||
# ---- edits write back to the Step -------------------------------------
|
||||
def _on_edit(self, *_a) -> None:
|
||||
if self._loading or self._step is None:
|
||||
return
|
||||
s = self._step
|
||||
s.label = self.label_edit.text()
|
||||
s.role = self.role_edit.text().upper() or "AGENT"
|
||||
s.icon = self.icon_edit.currentText().strip()
|
||||
s.instructions = self.instructions_edit.toPlainText()
|
||||
s.context = self.context_edit.toPlainText()
|
||||
s.model = self.model_combo.currentText().strip()
|
||||
s.permission_preset = self.perm_combo.currentData() or "inherit"
|
||||
s.self_verify = self.verify_chk.isChecked()
|
||||
s.max_verify_rounds = self.rounds_spin.value()
|
||||
s.skills = [self.skills_list.item(i).text()
|
||||
for i in range(self.skills_list.count())
|
||||
if self.skills_list.item(i).checkState() == Qt.Checked]
|
||||
self.changed.emit()
|
||||
|
||||
@staticmethod
|
||||
def _available_agent_names() -> List[str]:
|
||||
"""Agents the user can pick as a parallel sub-agent: their own custom
|
||||
agents first, then the built-in personas (kept for resolution even
|
||||
though they're no longer in the palette)."""
|
||||
from ..core import co4e
|
||||
from ..core.co4e_builtins import BUILTIN_AGENTS
|
||||
|
||||
names = [a.name for a in co4e.list_custom_agents()]
|
||||
names += [a.name for a in BUILTIN_AGENTS if a.name not in names]
|
||||
return names
|
||||
|
||||
def _add_subagent(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
if names:
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names, 0, True) # editable: can type a new one
|
||||
else:
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
self._step.sub_agents.append(SubAgent(agent=name))
|
||||
self.sub_list.addItem(name)
|
||||
self.changed.emit()
|
||||
|
||||
def _edit_subagent(self, item) -> None:
|
||||
"""Double-click a sub-agent row → re-pick from the list."""
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.sub_list.row(item)
|
||||
if not (0 <= row < len(self._step.sub_agents)):
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
cur = self._step.sub_agents[row].agent
|
||||
start = names.index(cur) if cur in names else 0
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names or [cur], start, True)
|
||||
name = (name or "").strip()
|
||||
if ok and name:
|
||||
self._step.sub_agents[row].agent = name
|
||||
item.setText(name)
|
||||
self.changed.emit()
|
||||
|
||||
def _del_subagent(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.sub_list.currentRow()
|
||||
if 0 <= row < len(self._step.sub_agents):
|
||||
self._step.sub_agents.pop(row)
|
||||
self.sub_list.takeItem(row)
|
||||
self.changed.emit()
|
||||
|
||||
def _add_attachment(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
from pathlib import Path as _P
|
||||
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("co4e.attach_add"))
|
||||
for f in files:
|
||||
if f and f not in self._step.attachments:
|
||||
self._step.attachments.append(f)
|
||||
item = QListWidgetItem(_P(f).name)
|
||||
item.setToolTip(f)
|
||||
self.attach_list.addItem(item)
|
||||
if files:
|
||||
self.changed.emit()
|
||||
|
||||
def _del_attachment(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.attach_list.currentRow()
|
||||
if 0 <= row < len(self._step.attachments):
|
||||
self._step.attachments.pop(row)
|
||||
self.attach_list.takeItem(row)
|
||||
self.changed.emit()
|
||||
|
||||
def _ai_draft(self) -> None:
|
||||
"""Draft this step's instructions from its label (name) + role — first
|
||||
asking for an optional description so the generated instructions can be
|
||||
more specific/detailed than name+role alone would produce."""
|
||||
if self.ctx is None or self._step is None:
|
||||
return
|
||||
from ..core.worker import AgentWorker
|
||||
|
||||
name = self.label_edit.text().strip()
|
||||
role = self.role_edit.text().strip()
|
||||
if not name and not role:
|
||||
return
|
||||
hint, ok = QInputDialog.getMultiLineText(
|
||||
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
|
||||
if not ok:
|
||||
return
|
||||
hint = hint.strip()
|
||||
self.gen_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..core.ai_task_planner import generate_agent_prompt
|
||||
return {"text": generate_agent_prompt(ctx.build_active_provider(), name, role, hint,
|
||||
cancel=worker.is_cancelled)}
|
||||
|
||||
def done(result: dict):
|
||||
self.gen_btn.setEnabled(True)
|
||||
if result.get("text"):
|
||||
self.instructions_edit.setPlainText(result["text"]) # _on_edit persists it
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda _e: self.gen_btn.setEnabled(True))
|
||||
self._draft_worker = w
|
||||
w.start()
|
||||
|
||||
def _load_models(self) -> None:
|
||||
if self.ctx is None:
|
||||
return
|
||||
from ..core import preview_ai
|
||||
from ..core.worker import AgentWorker
|
||||
|
||||
self.load_models_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_w):
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict):
|
||||
self.load_models_btn.setEnabled(True)
|
||||
models = []
|
||||
for lst in (result or {}).values():
|
||||
models.extend(lst)
|
||||
cur = self.model_combo.currentText()
|
||||
self.model_combo.blockSignals(True)
|
||||
self.model_combo.clear()
|
||||
self.model_combo.addItems(sorted(set(models)))
|
||||
self.model_combo.setEditText(cur)
|
||||
self.model_combo.blockSignals(False)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda _e: self.load_models_btn.setEnabled(True))
|
||||
self._model_worker = w
|
||||
w.start()
|
||||
__all__ = ["StepConfigPanel"]
|
||||
|
||||
+70
-281
@@ -15,13 +15,11 @@ persona and ``/skill:<name>`` applies a skill — same as Cowork.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QMimeData, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QDrag
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFrame, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
|
||||
QListView, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPushButton,
|
||||
@@ -36,9 +34,16 @@ from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .chat_view import ChatView
|
||||
from .co4e_canvas import CO4E_MIME, Co4ECanvas
|
||||
from .co4e_canvas import Co4ECanvas
|
||||
from .co4e_config_panel import StepConfigPanel
|
||||
from .icons import icon
|
||||
from ..presentation.co4e.agent_list_panel import AgentListPanel
|
||||
from ..presentation.co4e.co4e_chat_view import (
|
||||
ChatPanel, _ChatInput, _agent_names, _directive_token, _skill_names,
|
||||
)
|
||||
from ..presentation.co4e.co4e_run_control_widget import RunsPagePanel
|
||||
from ..presentation.co4e.palette_list import _PaletteList
|
||||
from ..presentation.co4e.skills_list_panel import SkillsListPanel
|
||||
|
||||
|
||||
_PLAN_GLYPH = {"completed": "✓", "done": "✓", "in_progress": "▶", "running": "▶",
|
||||
@@ -57,19 +62,6 @@ def _fmt_plan(steps) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _skill_names() -> List[str]:
|
||||
try:
|
||||
return [s.name for s in skills_mod.list_skills() + skills_mod.builtin_skills()]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
def _agent_names() -> List[str]:
|
||||
names = [a.name for a in co4e.list_custom_agents()]
|
||||
names += [a.name for a in BUILTIN_AGENTS if a.name not in names]
|
||||
return names
|
||||
|
||||
|
||||
class _EqualTabBar(QTabBar):
|
||||
"""Icon-only sidebar tabs (Workflows / Agents / Skills), all the same width,
|
||||
sized to fill the sidebar with a comfortable minimum (~double the default
|
||||
@@ -93,138 +85,6 @@ class _EqualTabBar(QTabBar):
|
||||
self.updateGeometry() # re-hint tab widths when resized
|
||||
|
||||
|
||||
class _PaletteList(QListWidget):
|
||||
"""A list whose rows can be dragged onto the canvas. Each item carries a
|
||||
JSON-able drag payload (a step dict, or a workflow dict) in ``payload_role``.
|
||||
Workflow rows also keep a (kind, id) tuple in Qt.UserRole for load/delete."""
|
||||
|
||||
def __init__(self, parent=None, payload_role=Qt.UserRole):
|
||||
super().__init__(parent)
|
||||
self._payload_role = payload_role
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QListWidget.DragOnly)
|
||||
|
||||
def startDrag(self, _actions): # noqa: N802
|
||||
item = self.currentItem()
|
||||
if item is None:
|
||||
return
|
||||
payload = item.data(self._payload_role)
|
||||
if not payload:
|
||||
return
|
||||
md = QMimeData()
|
||||
md.setData(CO4E_MIME, json.dumps(payload).encode("utf-8"))
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(md)
|
||||
drag.exec(Qt.CopyAction)
|
||||
|
||||
|
||||
def _directive_token(text: str, pos: int):
|
||||
"""Locate a ``/skill[:x]`` or ``/agent[:x]`` directive the cursor is on,
|
||||
anywhere in the line. Returns ``(start, kind, partial)`` or ``None``."""
|
||||
before = text[:pos]
|
||||
start = re.search(r"\S*$", before).start()
|
||||
token = before[start:]
|
||||
m = re.match(r"^/(skill|agent):?([\w\-.]*)$", token)
|
||||
if m:
|
||||
return start, m.group(1), m.group(2)
|
||||
for kind in ("skill", "agent"):
|
||||
if len(token) >= 2 and ("/" + kind).startswith(token):
|
||||
return start, kind, ""
|
||||
return None
|
||||
|
||||
|
||||
class _ChatInput(QLineEdit):
|
||||
"""Chat box with ``/skill:`` and ``/agent:`` autocomplete (parity with the
|
||||
Cowork composer). The popup never grabs focus, so typing keeps flowing."""
|
||||
|
||||
submit = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._popup = QListWidget()
|
||||
self._popup.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
|
||||
| Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
|
||||
self._popup.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
self._popup.setFocusPolicy(Qt.NoFocus)
|
||||
self._popup.itemClicked.connect(lambda _i: self._accept())
|
||||
self.textEdited.connect(self._maybe_popup)
|
||||
|
||||
def _maybe_popup(self, *_a) -> None:
|
||||
tok = _directive_token(self.text(), self.cursorPosition())
|
||||
if tok is None:
|
||||
self._popup.hide()
|
||||
return
|
||||
_start, kind, partial = tok
|
||||
f = partial.lower()
|
||||
self._popup.clear()
|
||||
if kind == "skill":
|
||||
for name in _skill_names():
|
||||
if f in name.lower():
|
||||
self._add_row(name, f"/skill:{co4e.slugify(name)} ", name)
|
||||
else:
|
||||
for name in _agent_names():
|
||||
if f in name.lower():
|
||||
self._add_row(name, f"/agent:{name} ", name)
|
||||
if self._popup.count() == 0:
|
||||
self._popup.hide()
|
||||
return
|
||||
self._popup.setCurrentRow(0)
|
||||
rows = min(7, self._popup.count())
|
||||
h = 8 + rows * 22
|
||||
self._popup.resize(max(280, self.width()), h)
|
||||
tl = self.mapToGlobal(self.rect().topLeft())
|
||||
self._popup.move(tl.x(), tl.y() - h - 2)
|
||||
self._popup.show()
|
||||
|
||||
def _add_row(self, label: str, replacement: str, tip: str) -> None:
|
||||
it = QListWidgetItem(label)
|
||||
it.setData(Qt.UserRole, replacement)
|
||||
it.setToolTip(tip)
|
||||
self._popup.addItem(it)
|
||||
|
||||
def _accept(self) -> None:
|
||||
item = self._popup.currentItem()
|
||||
self._popup.hide()
|
||||
if item is None:
|
||||
return
|
||||
replacement = item.data(Qt.UserRole)
|
||||
tok = _directive_token(self.text(), self.cursorPosition())
|
||||
start = tok[0] if tok else self.cursorPosition()
|
||||
pos = self.cursorPosition()
|
||||
full = self.text()
|
||||
new_text = full[:start] + replacement + full[pos:]
|
||||
self.setText(new_text)
|
||||
self.setCursorPosition(start + len(replacement))
|
||||
self.setFocus()
|
||||
|
||||
def focusOutEvent(self, e): # noqa: N802
|
||||
if not self._popup.underMouse():
|
||||
self._popup.hide()
|
||||
super().focusOutEvent(e)
|
||||
|
||||
def keyPressEvent(self, e): # noqa: N802
|
||||
if self._popup.isVisible():
|
||||
k = e.key()
|
||||
n = self._popup.count()
|
||||
if k in (Qt.Key_Down, Qt.Key_Up) and n:
|
||||
step = 1 if k == Qt.Key_Down else -1
|
||||
self._popup.setCurrentRow((self._popup.currentRow() + step) % n)
|
||||
return
|
||||
if k in (Qt.Key_Tab,):
|
||||
self._accept()
|
||||
return
|
||||
if k == Qt.Key_Escape:
|
||||
self._popup.hide()
|
||||
return
|
||||
if k in (Qt.Key_Return, Qt.Key_Enter):
|
||||
self._accept()
|
||||
return
|
||||
if e.key() in (Qt.Key_Return, Qt.Key_Enter):
|
||||
self.submit.emit()
|
||||
return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
|
||||
class Co4ETab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
@@ -546,38 +406,31 @@ class Co4ETab(QWidget):
|
||||
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
|
||||
|
||||
# --- AGENTS ------------------------------------------------------
|
||||
self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus"))
|
||||
self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
||||
self.ag_new_btn.setObjectName("co4eSectionAction")
|
||||
self.ag_new_btn.setFlat(True)
|
||||
self.ag_new_btn.setCursor(Qt.PointingHandCursor)
|
||||
# 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)
|
||||
ag_body = QWidget(); al = QVBoxLayout(ag_body)
|
||||
al.setContentsMargins(0, 0, 0, 0); al.setSpacing(4)
|
||||
self.agent_list = _PaletteList()
|
||||
al.addWidget(self.agent_list, 1)
|
||||
ag_btns = QHBoxLayout(); ag_btns.setSpacing(4)
|
||||
# Edit/delete act on the selected row, so they stay with the list.
|
||||
self.ag_edit_btn = self._icon_btn("edit", "co4e.tt_edit_agent", self._edit_agent)
|
||||
self.ag_del_btn = self._icon_btn("trash", "co4e.tt_del_agent", self._delete_agent)
|
||||
ag_btns.addWidget(self.ag_edit_btn)
|
||||
ag_btns.addWidget(self.ag_del_btn)
|
||||
ag_btns.addStretch(1)
|
||||
al.addLayout(ag_btns)
|
||||
col.addWidget(self._section("co4e.tab_agents", ag_body, self.ag_new_btn), 3)
|
||||
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 ------------------------------------------------------
|
||||
self.sk_manage_btn = QPushButton(tr("co4e.manage_skills"))
|
||||
self.sk_manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
|
||||
self.sk_manage_btn.setObjectName("co4eSectionAction")
|
||||
self.sk_manage_btn.setFlat(True)
|
||||
self.sk_manage_btn.setCursor(Qt.PointingHandCursor)
|
||||
# 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)
|
||||
sk_body = QWidget(); sl = QVBoxLayout(sk_body)
|
||||
sl.setContentsMargins(0, 0, 0, 0); sl.setSpacing(4)
|
||||
self.skill_list = _PaletteList()
|
||||
sl.addWidget(self.skill_list, 1)
|
||||
col.addWidget(self._section("co4e.tab_skills", sk_body, self.sk_manage_btn), 2)
|
||||
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
|
||||
@@ -874,63 +727,32 @@ class Co4ETab(QWidget):
|
||||
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."""
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
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.runs_back_btn = QPushButton(tr("co4e.back_to_flow"))
|
||||
self.runs_back_btn.setIcon(icon("chevron-left"))
|
||||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
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))
|
||||
hdr.addWidget(self.runs_back_btn)
|
||||
self.runs_title = QLabel(tr("co4e.running_flows"))
|
||||
self.runs_title.setObjectName("hint")
|
||||
hdr.addWidget(self.runs_title)
|
||||
# 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)
|
||||
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()
|
||||
hdr.addWidget(self.ws_folder_btn)
|
||||
hdr.addStretch(1)
|
||||
self.run_stop_btn = QPushButton(tr("co4e.stop"))
|
||||
self.run_stop_btn.setIcon(icon("stop"))
|
||||
self.run_stop_btn.setObjectName("danger")
|
||||
self.run_stop_btn.setToolTip(tr("co4e.tt_stop_run"))
|
||||
self.run_stop_btn = panel.stop_btn
|
||||
self.run_stop_btn.clicked.connect(self._stop_selected_run)
|
||||
self.run_rename_btn = QPushButton(tr("co4e.rename_run"))
|
||||
self.run_rename_btn.setIcon(icon("edit"))
|
||||
self.run_rename_btn.setToolTip(tr("co4e.tt_rename_run"))
|
||||
self.run_rename_btn = panel.rename_btn
|
||||
self.run_rename_btn.clicked.connect(self._rename_selected_run)
|
||||
self.run_del_btn = QPushButton(tr("co4e.delete_run"))
|
||||
self.run_del_btn.setIcon(icon("trash"))
|
||||
self.run_del_btn.setToolTip(tr("co4e.tt_delete_run"))
|
||||
self.run_del_btn = panel.del_btn
|
||||
self.run_del_btn.clicked.connect(self._delete_selected_run)
|
||||
self.run_clear_btn = QPushButton(tr("co4e.clear_done"))
|
||||
self.run_clear_btn.setToolTip(tr("co4e.tt_clear_runs"))
|
||||
self.run_clear_btn = panel.clear_btn
|
||||
self.run_clear_btn.clicked.connect(lambda: self.manager.clear_finished())
|
||||
hdr.addWidget(self.run_stop_btn)
|
||||
hdr.addWidget(self.run_rename_btn)
|
||||
hdr.addWidget(self.run_del_btn)
|
||||
hdr.addWidget(self.run_clear_btn)
|
||||
v.addLayout(hdr)
|
||||
self.runs_table = QTableWidget(0, 5)
|
||||
self.runs_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.runs_table.verticalHeader().setVisible(False)
|
||||
self.runs_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.runs_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.runs_table.setToolTip(tr("co4e.tt_runs_list"))
|
||||
self.runs_table = panel.table
|
||||
self.runs_table.itemDoubleClicked.connect(self._open_run_from_table)
|
||||
# Right-click a run → Open / Delete (delete a single old run from history).
|
||||
self.runs_table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.runs_table.customContextMenuRequested.connect(self._runs_context_menu)
|
||||
v.addWidget(self.runs_table, 1)
|
||||
return w
|
||||
return panel
|
||||
|
||||
def _wrap_config(self) -> QWidget:
|
||||
"""Wrap the step-config panel with a header that has an expand/collapse
|
||||
@@ -1063,69 +885,36 @@ class Co4ETab(QWidget):
|
||||
self.canvas.add_overlay(bar)
|
||||
|
||||
def _build_chat(self) -> QWidget:
|
||||
w = QWidget()
|
||||
self._chat_widget = w
|
||||
lay = QVBoxLayout(w)
|
||||
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._mhdr = QWidget(); self._mhdr.setObjectName("msgHeader")
|
||||
mh = QHBoxLayout(self._mhdr); 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)
|
||||
"""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)
|
||||
mh.addWidget(self.msgs_icon)
|
||||
mh.addWidget(self.msgs_title)
|
||||
mh.addStretch(1)
|
||||
mh.addWidget(self.chat_toggle_btn)
|
||||
lay.addWidget(self._mhdr) # 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.
|
||||
from PySide6.QtWidgets import QStackedWidget
|
||||
self.chat_stack = QStackedWidget()
|
||||
self._mhdr = panel.header
|
||||
self.chat_stack = panel.chat_stack
|
||||
self._flow_logs: Dict[str, ChatView] = {}
|
||||
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"))
|
||||
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 = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send"))
|
||||
self.chat_send_btn = panel.chat_send_btn
|
||||
self.chat_send_btn.clicked.connect(self._chat_send)
|
||||
row.addWidget(self.chat_input, 1)
|
||||
# Off/Auto/Manual routing toggle for Co4E (surface key "co4e").
|
||||
from .routing_toggle import RoutingToggle
|
||||
self.co4e_routing_toggle = RoutingToggle(self.ctx, "co4e")
|
||||
self.co4e_routing_toggle = panel.co4e_routing_toggle
|
||||
self._co4e_routed_provider = None # routing provider override for the next turn
|
||||
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._vsplit_sizes = [540, 220] # sizes to restore when expanded
|
||||
self._msgs_collapsed = True
|
||||
self.chat_stack.hide()
|
||||
self.chat_input_row.hide()
|
||||
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
|
||||
w.setMaximumHeight(self._mhdr.sizeHint().height() + 6)
|
||||
return w
|
||||
return panel
|
||||
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Show/hide the WHOLE chat box (message list + composer) below the
|
||||
|
||||
Reference in New Issue
Block a user