Merge remote-tracking branch 'origin/gamma/refactor'

This commit is contained in:
Hiep Ha Van
2026-08-25 23:55:46 +09:00
178 changed files with 35668 additions and 2404 deletions
+35 -28
View File
@@ -638,12 +638,16 @@ class ChatPanel(QWidget):
def _apply_routing(self, text: str, turn: Dict[str, Any]) -> None:
"""Auto Model Routing hook — run once per outgoing message.
Off → no-op. Auto → silently switch to the best-fit model. Manual → ask
the user (modal, with the configured confirm timeout) before switching.
Sets ``self._routed_provider``/``self._routed_model`` for THIS turn;
:meth:`build_provider` honours them. Never raises — a routing failure
must never block sending a message; it just falls back to the tab's
own model.
Since R03-T04 the Off/Auto/Manual/Fallback rules live in
``application/model_routing/routing_application_service.py``; the copy
that used to sit here (and again in Co4E and AI-Edit) is gone. What
remains is the widget's own job: snapshot the tab's provider/model into
a request, host the Manual-mode modal, and render the outcome by setting
``self._routed_provider``/``self._routed_model`` for THIS turn (honoured
by :meth:`build_provider`) plus a status bubble.
Never raises — a routing failure must never block sending a message; it
just falls back to the tab's own model.
"""
# Recompute fresh each message; clear any previous turn's override.
self._routed_provider = None
@@ -651,33 +655,36 @@ class ChatPanel(QWidget):
# An explicitly-pinned Admin agent takes precedence over routing.
if getattr(self, "_admin_agent", None) is not None:
return
if not (text or "").strip():
return
try:
mode = self.ctx.project_routing_mode(self.kind) # per-workspace mode
if mode == "off":
return
service = self.ctx.routing()
from ..application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from .routing_toggle import confirm_switch
# The model the tab WOULD use without routing — the picker's choice,
# or the provider's configured default when nothing is picked.
cur_provider = self.ctx.config.active_provider
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
result = service.route(self.kind, text, cur_provider, cur_model, mode_override=mode)
if not result.should_switch:
return
target = result.target()
if target is None:
return
to_provider, to_model = target
if mode == "manual":
from .routing_toggle import confirm_switch
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
if not confirm_switch(self, result.decision, timeout):
return # declined / timed out → keep current model
self._routed_provider = to_provider
self._routed_model = to_model
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface=self.kind, # per-workspace mode key ("cowork"/…)
prompt=text,
current_provider=cur_provider,
current_model=cur_model,
),
# Manual mode only: the modal stays in the presentation layer so
# the application service never imports Qt.
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return # off / nothing better / declined → keep the tab's model
self._routed_provider = outcome.provider
self._routed_model = outcome.model
notice = self.chat_view.add_status(tr(
"routing.switched_notice",
model=to_model, task=result.task_type.value,
gain=f"{result.decision.score_gain:.2f}"))
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
turn["bubbles"].append(notice)
except Exception: # noqa: BLE001 — routing must never block a chat turn
self._routed_provider = None
+22 -775
View File
@@ -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
View File
@@ -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"]
+101 -305
View File
@@ -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
@@ -1849,36 +1638,43 @@ class Co4ETab(QWidget):
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. Off → no-op. Manual → confirm first.
Never raises — falls back to the default model on any error."""
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
if not (request or "").strip():
return ""
try:
mode = self.ctx.project_routing_mode("co4e") # per-workspace mode
if mode == "off":
return ""
service = self.ctx.routing()
from ..application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from .routing_toggle import confirm_switch
cur_provider = self.ctx.config.active_provider
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
result = service.route("co4e", request, cur_provider, cur_model, mode_override=mode)
if not result.should_switch:
return ""
target = result.target()
if target is None:
return ""
to_provider, to_model = target
if mode == "manual":
from .routing_toggle import confirm_switch
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
if not confirm_switch(self, result.decision, timeout):
return ""
self._co4e_routed_provider = to_provider
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=to_model, task=result.task_type.value,
gain=f"{result.decision.score_gain:.2f}"))
return to_model
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 ""
+58 -19
View File
@@ -346,19 +346,45 @@ class CoworkTab(ChatPanel):
self._apply_output_folder_label() # picks up edits made via Settings too
def build_job(self, text: str, messages, out_dir):
# Each turn writes into its OWN isolated folder (out_dir) and works on its
# OWN message list, so several turns can run in parallel without clobbering
# each other's files or history. Deliverables are moved up to the session
# Output root when the turn finishes (see _cleanup_turn).
"""This turn's job: a frozen request run through the conversation service.
Since R04-T04 the widget no longer drives the turn loop. Every value a
turn depends on is read HERE, on the UI thread at submit time, and packed
into an immutable ``ConversationExecutionRequest`` — so clicking a
different model or switching workspace mid-answer cannot reach work
already in flight.
"""
output_dir = out_dir or self._session_output_dir()
# The sandbox folder is named by the turn id ('.turns/t3'); with no
# sandbox the session id identifies the turn well enough for the audit log.
turn_id = out_dir.name if out_dir is not None else self.session_id
session_id = self.session_id
title = self.title
project_id = self.project_id
home_output_root = self.workspace_dir()
# Captured at submit time (UI thread): the Admin-defined agent
# preset's instructions, if one is selected in the Agent picker.
agent_prompt = self.admin_agent_prompt()
# Per-workspace Auto-run override wins, else the global "confirm before
# running commands" setting. Frozen now, so a Settings change mid-turn
# cannot flip the rules this turn started under.
confirm_commands = self.ctx.project_confirm_commands()
# What the turn is recorded as running on. A routing override (R03) wins
# over the tab's own picker; '' means the provider's configured default.
# Informational only — an Admin-agent preset builds its own provider
# below, so treat these as the record, not the decision.
provider_id = self._routed_provider or self.ctx.config.active_provider
model = self._routed_model or self._model or ""
def job(worker: AgentWorker):
from ..core.chat_agent import run_cowork
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from ..application.conversations.cowork_turn_request import (
build_cowork_turn_request,
)
from ..application.conversations.turn_runtime import combine_instructions
from ..core.projects import load_project, project_context_text
provider = self.build_provider() # this tab's selected agent/model
@@ -367,23 +393,36 @@ class CoworkTab(ChatPanel):
# built-in MCP server auto-registered while signed in, see
# AppContext._ms365_builtin_connection / mcp_servers/ms365_server.py).
extra_tools, extra_exec = self.ctx.build_mcp_tools()
# Shared project instructions (Claude-Projects style) — refreshed
# each turn so edits in the Workspace screen apply immediately.
proj_ctx = project_context_text(load_project(project_id))
if agent_prompt:
proj_ctx = f"{proj_ctx}\n\n{agent_prompt}" if proj_ctx else agent_prompt
# Shared project instructions (Claude-Projects style) plus the Admin
# agent's persona, refreshed each turn so edits in the Workspace
# screen apply immediately.
instructions = combine_instructions(
project_context_text(load_project(project_id)), agent_prompt)
# Permission Management (Sandbox Security Layer): off by default —
# matches the pre-existing auto-run behavior. Now resolved PER
# WORKSPACE: this project's Auto-run override wins, else the global
# "confirm before running commands" setting (project_confirm_commands).
# matches the pre-existing auto-run behavior. The gate lives on the
# worker because the UI resolves it from the main thread.
gate = None
if self.ctx.project_confirm_commands():
if confirm_commands:
gate = worker.new_gate("confirm", agent_role=agent_roles.COWORK)
run_cowork(provider, messages, output_dir, worker.emit_event,
worker.is_cancelled, title=title,
extra_tools=extra_tools, extra_executor=extra_exec,
project_context=proj_ctx, security_config=self.ctx.config,
gate=gate)
service = build_cowork_conversation_service(
provider, output_dir, worker.emit_event, title=title,
project_context=instructions, extra_tools=extra_tools,
extra_executor=extra_exec, security_config=self.ctx.config,
gate=gate, agent_role=agent_roles.COWORK,
)
request = build_cowork_turn_request(
turn_id=turn_id, session_id=session_id, surface=self.kind,
project_id=project_id, title=title, messages=messages,
provider_id=provider_id, model=model, instructions=instructions,
output_dir=output_dir, home_output_root=home_output_root,
confirm_commands=gate is not None, agent_role=agent_roles.COWORK,
)
# Hand the widget's own list over: _reattach_running_turn replays
# from it while the turn is still running, and _finalize_turn slices
# it afterwards, so the service must append into that very object.
service.execute(request, legacy_event_sink(worker.emit_event),
cancel=worker.is_cancelled, messages=messages)
return {"messages": messages, "turn_dir": str(output_dir)}
return job
+26 -27
View File
@@ -923,43 +923,42 @@ class FolderTab(QWidget):
def _ai_apply_routing(self, instruction: str) -> None:
"""Auto Model Routing for the AI-Edit surface (always a CODING task).
Off → no-op. Auto → silently pick the best coding model. Manual → ask
first. Sets ``self._ai_routed_provider``/``_ai_routed_model`` for this
run; :meth:`_ai_provider` honours them. Never raises."""
R03-T05: routes through the shared ``RoutingApplicationService`` instead
of repeating the Off/Auto/Manual/Fallback rules locally. Sets
``self._ai_routed_provider``/``_ai_routed_model`` for this run;
:meth:`_ai_provider` honours them. Never raises."""
self._ai_routed_provider = None
self._ai_routed_model = None
if not (instruction or "").strip():
return
try:
from ..core.routing.models import TaskType
mode = self.ctx.project_routing_mode("ai_edit") # per-workspace mode
if mode == "off":
return
service = self.ctx.routing()
from ..application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from .routing_toggle import confirm_switch
cur_provider = self.ctx.config.active_provider
picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
result = service.route(
"ai_edit", instruction, cur_provider, cur_model,
mode_override=mode, task_type=TaskType.CODING,
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="ai_edit",
prompt=instruction,
current_provider=cur_provider,
current_model=cur_model,
# AI-Edit turns are always code edits, so the task type is
# pinned rather than classified from the instruction text.
task_type="coding",
),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not result.should_switch:
if not outcome.switched:
return
target = result.target()
if target is None:
return
to_provider, to_model = target
if mode == "manual":
from .routing_toggle import confirm_switch
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
if not confirm_switch(self, result.decision, timeout):
return
self._ai_routed_provider = to_provider
self._ai_routed_model = to_model
self._ai_routed_provider = outcome.provider
self._ai_routed_model = outcome.model
self.ai_chat.add_status(tr(
"routing.switched_notice",
model=to_model, task=result.task_type.value,
gain=f"{result.decision.score_gain:.2f}"))
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
except Exception: # noqa: BLE001 — routing must never block an edit
self._ai_routed_provider = None
self._ai_routed_model = None
+9 -5
View File
@@ -1,12 +1,13 @@
"""Off/Auto/Manual routing toggle + Auto-run toggle + Manual-mode confirm dialog.
"""Off/Auto/Manual/Fallback routing toggle + Auto-run toggle + confirm dialog.
Dropped into every chat surface's composer (Cowork / Co4E / AI-Edit). By
default a :class:`RoutingToggle` reads/writes the **per-workspace** mode via
``AppContext.project_routing_mode`` / ``set_project_routing_mode`` (so each
workspace keeps its own mode), but the storage is fully injectable through
``get_mode``/``set_mode`` callables — all the real decision logic lives in
``core/routing``. Call :meth:`refresh` when the active workspace changes so the
control shows that workspace's mode.
``application/model_routing`` (which the surfaces call through
``RoutingApplicationService``). Call :meth:`refresh` when the active workspace
changes so the control shows that workspace's mode.
"""
from __future__ import annotations
@@ -39,7 +40,7 @@ class RoutingToggle(QWidget):
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
"""
mode_changed = Signal(str) # "off" | "auto" | "manual"
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
def __init__(
self,
@@ -65,11 +66,14 @@ class RoutingToggle(QWidget):
self._label.setObjectName("hint")
self._combo = QComboBox()
self._combo.setToolTip(tr("routing.toggle_tooltip"))
# (data value, i18n key) — data is the persisted mode string.
# (data value, i18n key) — data is the persisted mode string. Order is
# least-to-most autonomous, with Fallback (R03-T03) last because it is
# the "only when something breaks" mode rather than a stronger Auto.
self._modes = [
("off", "routing.mode_off"),
("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"),
("fallback", "routing.mode_fallback"),
]
for value, key in self._modes:
self._combo.addItem(tr(key), value)
+70 -495
View File
@@ -1,29 +1,37 @@
"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group
(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place),
and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps)."""
from __future__ import annotations
"""Hộp thoại Cài đặt — khung lắp ráp.
from typing import Dict
Năm mục, mỗi mục một trang: Chung, AI Provider, Bảo mật sandbox, Tham số,
Auto Model Routing. Bốn mục đầu... đúng hơn: bốn trong năm mục đã bóc sang
``presentation/settings/`` (R08-T07); file này còn giữ mục Bảo mật sandbox,
phần lắp ráp danh sách mục bên trái, và ``_save`` gọi ``apply_to`` của từng
widget con.
Không còn phần Connector nào ở đây: nó đã dời sang Monitoring → Tools →
Connector từ trước. Ngày 25/08 dọn nốt 108 dòng MS365 chết còn sót lại của
lần dời đó — năm hàm gọi lẫn nhau, không đường vào, và đọc ba thuộc tính
chưa từng được gán nên gọi vào là AttributeError.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTreeWidget,
QMessageBox, QPushButton, QScrollArea, QSpinBox,
QTreeWidgetItem, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
from ..core.worker import AgentWorker
from ..i18n import LANGUAGES, tr
from ..state import AppContext
from .icons import icon, IconLabel
from .widgets import SegmentedControl, ToggleSwitch
from .ext_connector_dialog import ExtConnectorEditDialog
from ..i18n import tr
from .icons import IconLabel
from .widgets import ToggleSwitch
from ..presentation.settings.general_settings_widget import GeneralSettingsWidget
from ..presentation.settings.provider_settings_widget import ProviderSettingsWidget
from ..presentation.settings.parameter_settings_widget import ParameterSettingsWidget
from ..presentation.settings.routing_settings_widget import RoutingSettingsWidget
class SettingsDialog(QDialog):
def __init__(self, ctx, parent=None):
super().__init__()
@@ -50,63 +58,17 @@ class SettingsDialog(QDialog):
self._content = QWidget()
root = QVBoxLayout(self._content)
# --- language + tray ---
top = QFormLayout()
self.language_combo = SegmentedControl()
for key, label in LANGUAGES.items():
self.language_combo.addItem(label, key)
self._select_combo(self.language_combo, ctx.config.language)
top.addRow(tr("settings.language"), self.language_combo)
# Theme belongs with the other per-account settings. It is also on the
# rail's account row (one click for the common flip); this is the same
# value, named and explained, for people who come looking in Settings.
self.theme_combo = SegmentedControl()
for key in ("system", "dark", "light"):
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
top.addRow(tr("settings.theme"), self.theme_combo)
self.tray_chk = ToggleSwitch(tr("settings.tray_keep"))
self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True)))
top.addRow("", self.tray_chk)
self.notify_chk = ToggleSwitch(tr("settings.tray_notify"))
self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True)))
top.addRow("", self.notify_chk)
# Zero-height anchor so the index can scroll to this section, which is a
# bare form rather than a group box.
self._anchor_general = QWidget()
self._anchor_general.setFixedHeight(0)
root.addWidget(self._anchor_general)
root.addLayout(top)
# --- Chung: ngôn ngữ, giao diện, khay ---
# Đã bóc sang presentation/settings/general_settings_widget.py (R08-T07).
self._general_box = GeneralSettingsWidget(self.ctx)
root.addWidget(self._general_box)
self._load_workers = []
# --- AI Provider ---
self._prov_staging: Dict[str, dict] = {
key: dict(conf) for key, conf in data["providers"].items()
}
self.provider_combo = QComboBox()
for key, label in PROVIDER_LABELS.items():
self.provider_combo.addItem(label, key)
self._select_combo(self.provider_combo, ctx.config.active_provider)
self._prov_current_key = self.provider_combo.currentData()
conf = self._prov_staging.get(self._prov_current_key, {})
self.prov_base = QLineEdit(conf.get("base_url", ""))
self.prov_key = self._secret(conf.get("api_key", ""))
self.prov_model = self._model_combo(conf.get("model", ""))
self.prov_status = QLabel("")
self.prov_status.setObjectName("hint")
self.prov_status.setWordWrap(True)
prov_group = self._group(tr("settings.group.provider"), [
(tr("settings.active_provider"), self.provider_combo),
(tr("settings.base_url"), self.prov_base),
(tr("settings.api_key"), self.prov_key),
(tr("settings.model"), self._with_load(self.prov_model, self.prov_status)),
])
prov_group.layout().addRow("", self.prov_status)
self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed)
# Đã bóc sang presentation/settings/provider_settings_widget.py (R08-T07).
prov_group = ProviderSettingsWidget(self.ctx)
self._provider_page = prov_group
root.addWidget(prov_group)
# --- Sandbox Security Layer ---
@@ -177,141 +139,20 @@ class SettingsDialog(QDialog):
root.addWidget(self.sandbox_group)
# Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
# → Connector now — no connector UI in Settings. (_ms365_workers is kept
# for the dead-but-retained MS365 OAuth sign-in handlers below.)
self._ms365_workers = []
# --- Parameter ---
param_group = QGroupBox(tr("settings.group.parameter"))
pgl = QFormLayout(param_group)
def _param_section(key: str) -> None:
lbl = QLabel(tr(key))
lbl.setStyleSheet("font-weight:600; margin-top:6px;")
pgl.addRow(lbl)
# Parallel-conversation limit removed — conversations and flows now run
# unlimited in parallel (no cap, no Settings row).
att = data.get("attachments", {})
_param_section("settings.group.attachments")
self.attach_files = QSpinBox()
self.attach_files.setRange(1, 50)
self.attach_files.setSuffix(tr("settings.max_files_suffix"))
self.attach_files.setValue(max(1, int(att.get("max_files", 20))))
self.attach_files.setToolTip(tr("settings.max_files_tooltip"))
self.attach_tokens = QSpinBox()
self.attach_tokens.setRange(1, 1000)
self.attach_tokens.setSingleStep(5)
self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix"))
self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000))
self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip"))
pgl.addRow(tr("settings.max_files"), self.attach_files)
pgl.addRow(tr("settings.max_per_file"), self.attach_tokens)
st = data.get("structure", {})
_param_section("settings.group.structure")
self.struct_nodes = QSpinBox()
self.struct_nodes.setRange(0, 100000)
self.struct_nodes.setSpecialValueText(tr("settings.unlimited"))
self.struct_nodes.setSuffix(tr("settings.nodes_suffix"))
self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500))))
self.struct_nodes.setToolTip(tr("settings.nodes_tooltip"))
self.struct_edges = QSpinBox()
self.struct_edges.setRange(0, 200000)
self.struct_edges.setSpecialValueText(tr("settings.unlimited"))
self.struct_edges.setSuffix(tr("settings.edges_suffix"))
self.struct_edges.setValue(max(0, int(st.get("max_edges", 500))))
self.struct_edges.setToolTip(tr("settings.edges_tooltip"))
pgl.addRow(tr("settings.max_nodes"), self.struct_nodes)
pgl.addRow(tr("settings.max_edges"), self.struct_edges)
# Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from
# the Sandbox Security group; still stored under agent_security.*.
_param_section("settings.group.sandbox_limits")
self.sandbox_cpu = QSpinBox()
self.sandbox_cpu.setRange(0, 100_000)
self.sandbox_cpu.setSuffix(" %")
self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited"))
self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0))
pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu)
self.sandbox_memory = QSpinBox()
self.sandbox_memory.setRange(0, 1_000_000)
self.sandbox_memory.setSuffix(" MB")
self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited"))
self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048))
pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory)
self.sandbox_disk = QSpinBox()
self.sandbox_disk.setRange(0, 1_000_000)
self.sandbox_disk.setSuffix(" MB")
self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited"))
self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048))
pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk)
# Đã bóc sang presentation/settings/parameter_settings_widget.py (R08-T07).
param_group = ParameterSettingsWidget(self.ctx)
self._param_page = param_group
root.addWidget(param_group)
# ---- Auto Model Routing ------------------------------------------
routing = self.ctx.config.routing
routing_group = QGroupBox(tr("routing.settings_group"))
rgl = QFormLayout(routing_group)
self.routing_mode = QComboBox()
for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
("manual", "routing.mode_manual")):
self.routing_mode.addItem(tr(key), value)
self._select_combo(self.routing_mode, routing.get("switch_mode", "off"))
rgl.addRow(tr("routing.settings_mode"), self.routing_mode)
self.routing_policy = QComboBox()
for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")):
self.routing_policy.addItem(tr(key), value)
self._select_combo(self.routing_policy, routing.get("policy", "balanced"))
rgl.addRow(tr("routing.settings_policy"), self.routing_policy)
# Min score gain stored as a fraction (0..1); shown as a percentage.
self.routing_min_gain = QSpinBox()
self.routing_min_gain.setRange(0, 100)
self.routing_min_gain.setSuffix(" %")
self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100)))
rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain)
self.routing_timeout = QSpinBox()
self.routing_timeout.setRange(5, 600)
self.routing_timeout.setSuffix(" s")
self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60))
rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout)
self.routing_interval = QSpinBox()
self.routing_interval.setRange(0, 720)
self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled
self.routing_interval.setSuffix(" h")
self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0))
rgl.addRow(tr("routing.settings_interval"), self.routing_interval)
self.routing_concurrency = QSpinBox()
self.routing_concurrency.setRange(1, 16)
self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2))
rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency)
self.routing_judge = QLineEdit(routing.get("judge_model", ""))
rgl.addRow(tr("routing.settings_judge"), self.routing_judge)
self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now"))
self.routing_reassess_btn.clicked.connect(self._routing_reassess_now)
rgl.addRow("", self.routing_reassess_btn)
rhint = QLabel(tr("routing.settings_hint"))
rhint.setObjectName("hint")
rhint.setWordWrap(True)
rgl.addRow(rhint)
# Đã bóc sang presentation/settings/routing_settings_widget.py (R08-T07).
routing_group = RoutingSettingsWidget(self.ctx)
self._routing_page = routing_group
root.addWidget(routing_group)
note = QLabel(tr("settings.tip"))
note.setObjectName("hint")
note.setWordWrap(True) # otherwise this one line sets the dialog's width
root.addWidget(note)
# Left list + right panel: one group on screen at a time, the way the
# audit page's mock-up shows it. The five rows are the five real group
@@ -319,15 +160,6 @@ class SettingsDialog(QDialog):
# glance instead of by scrolling to find out.
from .widgets import section_panels
self._general_box = QWidget()
gv = QVBoxLayout(self._general_box)
gv.setContentsMargins(0, 0, 0, 0)
root.removeWidget(self._anchor_general)
root.removeItem(top)
gv.addLayout(top)
gv.addWidget(note) # the tip belongs with the general settings
gv.addStretch(1)
root.removeWidget(note)
pages = []
for label, widget in ((tr("settings.group.general"), self._general_box),
@@ -374,18 +206,38 @@ class SettingsDialog(QDialog):
self.resize(640, min(740, avail.height() - 80))
self.setMaximumHeight(avail.height())
# ---- cầu tương thích sau khi bóc Routing -----------------------------
# Năm checker trong tools/ và bài đặc tả đọc thẳng self.routing_*. Giữ tên
# cũ trỏ vào widget mới để việc bóc không kéo theo sửa chỗ khác — đây là
# đổi chỗ ở, không đổi hành vi. Bỏ được khi tools/ chuyển sang đọc
# self._routing_page.
provider_combo = property(lambda self: self._provider_page.provider_combo)
prov_base = property(lambda self: self._provider_page.prov_base)
prov_key = property(lambda self: self._provider_page.prov_key)
prov_model = property(lambda self: self._provider_page.prov_model)
prov_status = property(lambda self: self._provider_page.prov_status)
language_combo = property(lambda self: self._general_box.language_combo)
theme_combo = property(lambda self: self._general_box.theme_combo)
tray_chk = property(lambda self: self._general_box.tray_chk)
notify_chk = property(lambda self: self._general_box.notify_chk)
attach_files = property(lambda self: self._param_page.attach_files)
attach_tokens = property(lambda self: self._param_page.attach_tokens)
struct_nodes = property(lambda self: self._param_page.struct_nodes)
struct_edges = property(lambda self: self._param_page.struct_edges)
sandbox_cpu = property(lambda self: self._param_page.sandbox_cpu)
sandbox_memory = property(lambda self: self._param_page.sandbox_memory)
sandbox_disk = property(lambda self: self._param_page.sandbox_disk)
routing_mode = property(lambda self: self._routing_page.mode)
routing_policy = property(lambda self: self._routing_page.policy)
routing_min_gain = property(lambda self: self._routing_page.min_gain)
routing_timeout = property(lambda self: self._routing_page.timeout)
routing_interval = property(lambda self: self._routing_page.interval)
routing_concurrency = property(lambda self: self._routing_page.concurrency)
routing_judge = property(lambda self: self._routing_page.judge)
routing_reassess_btn = property(lambda self: self._routing_page.reassess_btn)
# ---- helpers -----------------------------------------------------
@staticmethod
def _secret(value: str) -> QLineEdit:
edit = QLineEdit(value)
edit.setEchoMode(QLineEdit.Password)
return edit
@staticmethod
def _select_combo(combo: QComboBox, value: str) -> None:
idx = combo.findData(value)
if idx >= 0:
combo.setCurrentIndex(idx)
@staticmethod
def _group(title: str, rows) -> QGroupBox:
@@ -395,268 +247,18 @@ class SettingsDialog(QDialog):
form.addRow(label, widget)
return box
def _routing_reassess_now(self) -> None:
"""Kick off a manual model reassessment in the background."""
try:
service = self.ctx.routing()
if service.is_reassessing():
return
self.routing_reassess_btn.setEnabled(False)
self.routing_reassess_btn.setText(tr("routing.reassessing"))
def _done(result) -> None:
# Re-enable from the (worker) callback; label reflects the count.
self.routing_reassess_btn.setEnabled(True)
self.routing_reassess_btn.setText(
tr("routing.reassess_done", count=len(result or {})))
service.reassess_background(on_done=_done)
except Exception: # noqa: BLE001 — a reassess click must never crash Settings
self.routing_reassess_btn.setEnabled(True)
self.routing_reassess_btn.setText(tr("routing.settings_reassess_now"))
@staticmethod
def _model_combo(value: str) -> QComboBox:
combo = QComboBox()
combo.setEditable(True)
# A combo sizes itself to its longest entry by default; model ids are
# long, so the row grew past the dialog and forced a sideways scrollbar
# (worse at 125%/150% display scaling). Let it shrink and use a popup
# wider than the closed box instead.
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
combo.setMinimumContentsLength(8)
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
if value:
combo.addItem(value)
combo.setCurrentText(value)
return combo
def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget:
row = QWidget()
lay = QHBoxLayout(row)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(combo, 1)
btn = QPushButton(tr("settings.load"))
btn.setIcon(icon("download"))
btn.setToolTip(tr("settings.load_tooltip"))
btn.clicked.connect(
lambda: self._load_models(self.provider_combo.currentData(), combo, status))
lay.addWidget(btn)
test_btn = QPushButton(tr("settings.test_connection"))
test_btn.setIcon(icon("flask"))
test_btn.setToolTip(tr("settings.test_connection_tooltip"))
test_btn.clicked.connect(
lambda: self._test_connection(self.provider_combo.currentData(), status))
lay.addWidget(test_btn)
# The two buttons keep their natural size; the combo gives way. Without
# this the row's minimum was combo + both buttons and nothing could
# shrink, so the dialog scrolled sideways instead.
for b in (btn, test_btn):
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
return row
def _stash_provider_fields(self) -> None:
staged = self._prov_staging.setdefault(self._prov_current_key, {})
staged.update({
"base_url": self.prov_base.text().strip(),
"api_key": self.prov_key.text(),
"model": self.prov_model.currentText().strip(),
})
def _on_provider_edit_changed(self) -> None:
self._stash_provider_fields()
self._prov_current_key = self.provider_combo.currentData()
conf = self._prov_staging.get(self._prov_current_key, {})
self.prov_base.setText(conf.get("base_url", ""))
self.prov_key.setText(conf.get("api_key", ""))
self.prov_model.clear()
if conf.get("model"):
self.prov_model.addItem(conf["model"])
self.prov_model.setCurrentText(conf["model"])
else:
self.prov_model.setCurrentText("")
self.prov_status.setText("")
def _current_conf(self, provider: str) -> dict:
if provider == self._prov_current_key:
return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(),
"model": self.prov_model.currentText().strip()}
conf = self._prov_staging.get(provider, {})
return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""),
"model": conf.get("model", "")}
# ---- MS365 zero-config sign-in ("connect like Claude") ---------------
def _refresh_ms365_status(self) -> None:
from ..core.ms365_auth import current_identity
who = current_identity(self.ctx.config)
if who:
self.ms365_status.setText(tr("settings.ms365_signed_in", who=who))
self.ms365_signin_btn.setEnabled(False)
self.ms365_signout_btn.setEnabled(True)
else:
self.ms365_status.setText(tr("settings.ms365_signed_out"))
self.ms365_signin_btn.setEnabled(True)
self.ms365_signout_btn.setEnabled(False)
self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn"))
self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn"))
def _ms365_sign_in(self) -> None:
from ..core.ms365_auth import current_identity, sign_in
self.ms365_signin_btn.setEnabled(False)
self.ms365_status.setText(tr("settings.ms365_signing_in"))
cfg = self.ctx.config
def job(worker):
# on_code fires (worker thread) with the MSAL device-flow dict —
# marshal it to the UI thread via the worker's event signal.
return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg)
def on_event(ev: dict) -> None:
if "device_flow" in ev:
self._show_ms365_device_code(ev["device_flow"])
def done(_result) -> None:
self._close_ms365_code_dialog()
self.ctx.save()
self._refresh_ms365_status()
QMessageBox.information(
self, tr("settings.ms365_signin_btn"),
tr("settings.ms365_signed_in", who=current_identity(cfg)))
def failed(err: str) -> None:
self._close_ms365_code_dialog()
self._refresh_ms365_status()
QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err)
w = AgentWorker(job)
w.event.connect(on_event)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._ms365_workers.append(w)
w.start()
def _close_ms365_code_dialog(self) -> None:
dlg = getattr(self, "_ms365_code_dialog", None)
if dlg is not None:
dlg.close()
self._ms365_code_dialog = None
def _show_ms365_device_code(self, flow: dict) -> None:
"""Auto-open the sign-in page + show the one-time code in a COPYABLE,
non-modal dialog (so the worker keeps polling and can auto-close it on
success). The code is also copied to the clipboard immediately."""
import webbrowser
code = flow.get("user_code", "")
url = flow.get("verification_uri", "https://microsoft.com/devicelogin")
# Auto-copy the code so the user can just paste it.
QGuiApplication.clipboard().setText(code)
# Auto-open the browser to the (code-prefilled, if available) sign-in page.
try:
webbrowser.open(flow.get("verification_uri_complete") or url)
except Exception: # noqa: BLE001 — a headless box just shows the link to click
pass
self._close_ms365_code_dialog()
dlg = QDialog(self)
dlg.setWindowTitle(tr("settings.ms365_signin_btn"))
dlg.setMinimumWidth(420)
lay = QVBoxLayout(dlg)
info = QLabel(tr("settings.ms365_code_hint", url=url))
info.setWordWrap(True)
info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction)
info.setOpenExternalLinks(True)
lay.addWidget(info)
code_row = QHBoxLayout()
code_edit = QLineEdit(code)
code_edit.setReadOnly(True)
f = code_edit.font()
f.setPointSize(f.pointSize() + 4)
f.setBold(True)
code_edit.setFont(f)
code_edit.setCursorPosition(0)
copy_btn = QPushButton(tr("settings.ms365_copy_code"))
copy_btn.setIcon(icon("document"))
copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code))
open_btn = QPushButton(tr("settings.ms365_open_link"))
open_btn.setIcon(icon("link"))
open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url))
code_row.addWidget(code_edit, 1)
code_row.addWidget(copy_btn)
code_row.addWidget(open_btn)
lay.addLayout(code_row)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(dlg.reject)
lay.addWidget(buttons)
self._ms365_code_dialog = dlg
dlg.show() # non-modal — sign-in polling continues; done() closes it
def _ms365_sign_out(self) -> None:
from ..core.ms365_auth import sign_out_default
sign_out_default(self.ctx.config)
self._refresh_ms365_status()
def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None:
conf = self._current_conf(provider)
def job(worker):
from ..providers import build_provider
prov = build_provider(provider, conf)
models = prov.list_models()
return {"models": models, "error": getattr(prov, "last_error", "")}
def done(result):
models = result.get("models") or []
current = combo.currentText().strip()
combo.clear()
if current:
combo.addItem(current)
for m in models:
if m != current:
combo.addItem(m)
combo.setCurrentText(current)
error = result.get("error", "")
if models:
status.setText(tr("settings.loaded_models", n=len(models),
provider=PROVIDER_LABELS.get(provider, provider)))
else:
status.setText(tr("settings.load_models_error", err=error or
tr("settings.load_models_error_unknown")))
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e)))
self._load_workers.append(w)
status.setText(tr("settings.loading_models"))
w.start()
def _test_connection(self, provider: str, status: QLabel) -> None:
conf = self._current_conf(provider)
def job(worker):
from ..providers import build_provider
ok, message = build_provider(provider, conf).test_connection()
return {"ok": ok, "message": message}
def done(result):
ok = result.get("ok")
status.setText(result.get("message", ""))
status.setStyleSheet("color: #090;" if ok else "color: #c00;")
def failed(e):
status.setText(str(e))
status.setStyleSheet("color: #c00;")
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._load_workers.append(w)
status.setText(tr("settings.testing_connection"))
w.start()
def _sandbox_unlock(self) -> None:
pw = self.sandbox_pw_edit.text()
@@ -674,19 +276,9 @@ class SettingsDialog(QDialog):
def _save(self) -> None:
data = self.ctx.config.data
data["active_provider"] = self.provider_combo.currentData()
data["language"] = self.language_combo.currentData()
# MainWindow._open_settings re-applies the theme after this returns, so
# writing the value here is enough to make it take effect.
data["theme"] = self.theme_combo.currentData()
self._provider_page.apply_to(data)
self._general_box.apply_to(data)
self._stash_provider_fields()
for key, staged in self._prov_staging.items():
data["providers"].setdefault(key, {}).update({
"base_url": staged.get("base_url", ""),
"api_key": staged.get("api_key", ""),
"model": staged.get("model", ""),
})
# NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now
# (persisted there directly), so it is intentionally not written here.
@@ -696,28 +288,11 @@ class SettingsDialog(QDialog):
"block_network": self.sandbox_block_network.isChecked(),
"command_ai_check": self.ai_check.isChecked(),
"command_whitelist": [],
"resource_limit_cpu_percent": self.sandbox_cpu.value(),
"resource_limit_memory_mb": self.sandbox_memory.value(),
"resource_limit_disk_mb": self.sandbox_disk.value(),
})
att = data.setdefault("attachments", {})
att["max_tokens"] = self.attach_tokens.value() * 1000
att["max_files"] = self.attach_files.value()
st = data.setdefault("structure", {})
st["max_nodes"] = self.struct_nodes.value()
st["max_edges"] = self.struct_edges.value()
tray = data.setdefault("tray", {})
tray["minimize_on_close"] = self.tray_chk.isChecked()
tray["notify_on_done"] = self.notify_chk.isChecked()
self._param_page.apply_limits_to(data["agent_security"])
self._param_page.apply_to(data)
r = data.setdefault("routing", {})
r["switch_mode"] = self.routing_mode.currentData()
r["policy"] = self.routing_policy.currentData()
r["min_score_gain"] = self.routing_min_gain.value() / 100.0
r["confirm_timeout_sec"] = self.routing_timeout.value()
r["reassess_interval_hours"] = self.routing_interval.value()
r["per_provider_concurrency"] = self.routing_concurrency.value()
r["judge_model"] = self.routing_judge.text().strip()
self._routing_page.apply_to(data)
self.ctx.save()