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