CI / test (push) Canceled after 0s
## Summary What changed and why? ## Change Type - [ ] Cowork feature - [ ] Bug fix - [x] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Co-authored-by: NamPDT <minhanhpkpro@gmail.com> Reviewed-on: #3
2090 lines
97 KiB
Python
2090 lines
97 KiB
Python
"""Co4E — node-graph workflow studio (a Workspace sub-tab).
|
||
|
||
Layout (3 columns): left sidebar (Workflows / Agents / Skills, with CRUD,
|
||
drag-to-canvas, run-in-background + a live "Running flows" status list) | center
|
||
(compact toolbar + node canvas + bottom Chat/Output) | right (step config panel).
|
||
|
||
Runs: pick a mode — Auto (each step's agent plans then executes), Plan
|
||
(read-only, each step only drafts a plan), or Manual (step-by-step, advance with
|
||
"Next step"). Adjacent steps feed the next automatically (no manual wiring).
|
||
Several flows can run at once (foreground on the canvas + background from the
|
||
Workflows list); the run manager keeps their status live across sub-tab switches.
|
||
|
||
Chat supports inline directives with autocomplete: ``/agent:<name>`` picks a
|
||
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.QtWidgets import (
|
||
QComboBox, QFrame, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
|
||
QListView, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPushButton,
|
||
QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTableWidget,
|
||
QTableWidgetItem, QTabWidget, QTextBrowser, QVBoxLayout, QWidget,
|
||
)
|
||
|
||
from ..core import co4e, skills as skills_mod
|
||
from ..core.co4e_builtins import BUILTIN_AGENTS
|
||
from ..core.co4e_run_manager import Co4ERunManager
|
||
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_config_panel import StepConfigPanel
|
||
from .icons import icon
|
||
|
||
|
||
_PLAN_GLYPH = {"completed": "✓", "done": "✓", "in_progress": "▶", "running": "▶",
|
||
"error": "✗", "pending": "○", "todo": "○"}
|
||
|
||
|
||
def _fmt_plan(steps) -> str:
|
||
"""Render plan steps ``[{title,status}]`` as a ticked-off checklist."""
|
||
lines = []
|
||
for s in steps or []:
|
||
title = str((s or {}).get("title", "")).strip()
|
||
if not title:
|
||
continue
|
||
glyph = _PLAN_GLYPH.get(str((s or {}).get("status", "pending")).lower(), "○")
|
||
lines.append(f"{glyph} {title}")
|
||
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
|
||
icon-only width so they read as proper buttons) and an even gap between them.
|
||
The icon sits centered in each tab."""
|
||
|
||
_GAP = 6 # px between tabs — matches the QSS margin-right below
|
||
|
||
def tabSizeHint(self, index): # noqa: N802
|
||
base = super().tabSizeHint(index)
|
||
n = self.count() or 1
|
||
avail = self.width()
|
||
if avail <= 1: # width not resolved yet → use parent
|
||
p = self.parentWidget()
|
||
avail = p.width() if p is not None else 0
|
||
share = (avail - n * self._GAP) // n if avail > 1 else 0
|
||
return QSize(max(56, share), max(30, base.height()))
|
||
|
||
def resizeEvent(self, e): # noqa: N802
|
||
super().resizeEvent(e)
|
||
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)
|
||
|
||
def __init__(self, ctx):
|
||
super().__init__()
|
||
self.ctx = ctx
|
||
self._wf: co4e.Workflow = co4e.new_workflow(tr("co4e.untitled"))
|
||
self._chat_worker: Optional[AgentWorker] = None
|
||
# run state
|
||
self.manager = Co4ERunManager(ctx)
|
||
self.manager.changed.connect(self._refresh_runs)
|
||
self.manager.event.connect(self._on_manager_event)
|
||
# Per-flow run state so any number of flow tabs run in PARALLEL without
|
||
# mixing (bounded only by the machine — each run is its own QThread).
|
||
self._flow_runs: Dict[str, str] = {} # wf_id -> its active canvas run id
|
||
self._run_logs: Dict[str, "ChatView"] = {} # run_id -> that flow's chat log
|
||
self._flow_outputs: Dict[str, Dict[str, str]] = {} # wf_id -> {node_id: output}
|
||
# Running token/cost total per flow (↓in ↑out ▤ctx $cost), shown in the
|
||
# Messages header like Cowork's conversation total.
|
||
self._flow_usage: Dict[str, Dict[str, float]] = {}
|
||
self._project_id: str = "" # selected Workspace project
|
||
self._project_dir: Optional[Path] = None # its workspace folder (flow output goes here)
|
||
# manual mode
|
||
self._manual_active = False
|
||
self._manual_order: List[str] = []
|
||
self._manual_idx = 0
|
||
# open flows shown as browser-style tabs (each its own graph; runs are
|
||
# independent via the run manager)
|
||
self._flows: List[co4e.Workflow] = []
|
||
self._active_flow_idx = -1
|
||
|
||
root = QHBoxLayout(self)
|
||
self._split = QSplitter(Qt.Horizontal)
|
||
root.addWidget(self._split)
|
||
|
||
sidebar = self._build_sidebar()
|
||
sidebar.setMinimumWidth(180) # 210 pushed the whole tab past 1214px min
|
||
self._split.addWidget(sidebar)
|
||
self._split.addWidget(self._build_center())
|
||
self.config = StepConfigPanel(ctx)
|
||
self.config.setMinimumWidth(300) # so fields (incl. the model row) are never clipped
|
||
self.config.changed.connect(self._on_config_changed)
|
||
self.config.run_node.connect(lambda nid: self._run_single(nid))
|
||
self.config.run_from.connect(self._run_from)
|
||
self.config.delete_node.connect(self.canvas.delete_node)
|
||
self._config_collapsed = False
|
||
self._config_expanded_w = 360
|
||
self._split.addWidget(self._wrap_config())
|
||
from PySide6.QtCore import QTimer
|
||
|
||
from .widgets import narrow_guard
|
||
self._narrow_guard = narrow_guard(self, self._NARROW, self._apply_narrow_layout)
|
||
# Deferred one tick: the parent chain (and therefore window()) only
|
||
# exists after whoever is building this has finished adding it.
|
||
QTimer.singleShot(0, self._narrow_guard.attach)
|
||
# Start the sidebar wider than its 180px floor — at the floor the
|
||
# "Chạy nền" button and the flow names are cut off.
|
||
self._split.setSizes([230, 720, 360])
|
||
self._split.setStretchFactor(0, 0)
|
||
self._split.setStretchFactor(1, 1)
|
||
self._split.setStretchFactor(2, 0)
|
||
self._split.setStretchFactor(0, 0)
|
||
self._split.setStretchFactor(1, 1)
|
||
self._split.setStretchFactor(2, 0)
|
||
self._split.setSizes([250, 780, 360])
|
||
self._split.setChildrenCollapsible(True)
|
||
|
||
self.canvas.node_selected.connect(self._on_node_selected)
|
||
self.canvas.node_activated.connect(self._on_node_selected)
|
||
self.canvas.graph_changed.connect(self._autosave)
|
||
|
||
self._reload_sidebar()
|
||
self._open_flow(self._wf) # first browser-style flow tab
|
||
self._retranslate() # set Runs table headers etc.
|
||
self._refresh_runs()
|
||
on_language_changed(self._retranslate)
|
||
|
||
# ---- flow tabs (browser-style: several open flows, independent) --------
|
||
def _open_flow(self, wf: co4e.Workflow) -> None:
|
||
"""Open ``wf`` in a tab — reuse its tab if already open (like a browser),
|
||
else add a new one and switch to it. Bar index 0 is the pinned Runs tab,
|
||
so flow ``i`` lives at bar index ``i + 1``. If the flow has an active run,
|
||
its live status is reflected on the canvas."""
|
||
for i, f in enumerate(self._flows):
|
||
if f.id == wf.id:
|
||
self._flows[i] = wf
|
||
bar_idx = i + 1
|
||
self.flow_bar.setTabText(bar_idx, wf.name or tr("co4e.untitled"))
|
||
if self.flow_bar.currentIndex() == bar_idx:
|
||
self._active_flow_idx = -1 # force reload of same tab
|
||
self._on_flow_tab_changed(bar_idx)
|
||
else:
|
||
self.flow_bar.setCurrentIndex(bar_idx)
|
||
self._reflect_active_run(wf.id)
|
||
return
|
||
# Without the strip there is nowhere to switch between open flows, so
|
||
# opening one REPLACES the one on the canvas (saved first, as the tab
|
||
# switch used to do). Runs already in progress are unaffected — they are
|
||
# tracked per flow id and keep going in the background.
|
||
self._close_other_flows()
|
||
self._flows.append(wf)
|
||
self.flow_bar.blockSignals(True)
|
||
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
|
||
self._add_tab_close_button(bar_idx)
|
||
self.flow_bar.blockSignals(False)
|
||
if self.flow_bar.currentIndex() == bar_idx:
|
||
self._on_flow_tab_changed(bar_idx) # already current → load manually
|
||
else:
|
||
self.flow_bar.setCurrentIndex(bar_idx)
|
||
self._reflect_active_run(wf.id)
|
||
|
||
def _close_other_flows(self) -> None:
|
||
"""Leave the canvas empty of flows, saving whatever was on it.
|
||
|
||
Called before opening a flow, because the tab strip that used to hold
|
||
several at once is gone. Tab 0 (Runs) is never touched.
|
||
"""
|
||
if not self._flows:
|
||
return
|
||
if 0 <= self._active_flow_idx < len(self._flows):
|
||
self._sync_wf_from_canvas()
|
||
self.flow_bar.blockSignals(True)
|
||
for idx in range(self.flow_bar.count() - 1, 0, -1):
|
||
self.flow_bar.removeTab(idx)
|
||
self.flow_bar.blockSignals(False)
|
||
self._flows.clear()
|
||
self._active_flow_idx = -1
|
||
|
||
def _show_runs(self, on: bool) -> None:
|
||
"""Swap the centre between the flow editor and the Runs table.
|
||
|
||
This is where the pinned "Runs" tab went when the strip was removed —
|
||
same page, same table, reached from a toggle in the flow toolbar.
|
||
"""
|
||
target = 0 if on else min(1, self.flow_bar.count() - 1)
|
||
if self.flow_bar.currentIndex() == target:
|
||
self._on_flow_tab_changed(target) # already there → re-apply
|
||
else:
|
||
self.flow_bar.setCurrentIndex(target)
|
||
|
||
def _on_flow_tab_changed(self, idx: int) -> None:
|
||
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
|
||
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
|
||
self._sync_wf_from_canvas()
|
||
if idx <= 0: # the Runs page
|
||
self._active_flow_idx = -1
|
||
self.center_stack.setCurrentIndex(0)
|
||
self._sync_runs_toggle(True)
|
||
self._refresh_runs()
|
||
return
|
||
flow_idx = idx - 1
|
||
if not (0 <= flow_idx < len(self._flows)):
|
||
return
|
||
self._active_flow_idx = flow_idx
|
||
self.center_stack.setCurrentIndex(1)
|
||
self._sync_runs_toggle(False)
|
||
self._apply_workflow(self._flows[flow_idx])
|
||
|
||
def _sync_runs_toggle(self, on: bool) -> None:
|
||
"""Keep the Runs toggle showing which page is up, however it got there
|
||
(a double-click in the runs table also switches pages)."""
|
||
btn = getattr(self, "runs_btn", None)
|
||
if btn is not None and btn.isChecked() != on:
|
||
blocked = btn.blockSignals(True)
|
||
btn.setChecked(on)
|
||
btn.blockSignals(blocked)
|
||
|
||
def _add_tab_close_button(self, idx: int) -> None:
|
||
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
|
||
the tab's right side, vertically centered and INSIDE the tab (reliable
|
||
across themes, unlike the CSS-positioned default which looked detached)."""
|
||
btn = QPushButton("×") # ×
|
||
btn.setObjectName("flowTabClose")
|
||
btn.setFlat(True)
|
||
btn.setFixedSize(16, 16)
|
||
btn.setCursor(Qt.PointingHandCursor)
|
||
btn.clicked.connect(lambda: self._close_flow_tab_button(btn))
|
||
self.flow_bar.setTabButton(idx, QTabBar.RightSide, btn)
|
||
|
||
def _close_flow_tab_button(self, btn) -> None:
|
||
for i in range(self.flow_bar.count()):
|
||
if self.flow_bar.tabButton(i, QTabBar.RightSide) is btn:
|
||
self._close_flow_tab(i)
|
||
return
|
||
|
||
def _close_flow_tab(self, idx: int) -> None:
|
||
if idx <= 0: # Runs tab is pinned
|
||
return
|
||
flow_idx = idx - 1
|
||
if not (0 <= flow_idx < len(self._flows)):
|
||
return
|
||
closing = self._flows[flow_idx]
|
||
# Stop mirroring the closed flow's run onto the canvas — the run itself
|
||
# keeps going in the background and stays in Flow Status. (Per-flow run
|
||
# tracking: only this flow's entry is dropped; other flows keep running.)
|
||
rid = self._flow_runs.pop(closing.id, None)
|
||
if rid is not None:
|
||
self._run_logs.pop(rid, None)
|
||
if getattr(self, "_wf", None) is not None and self._wf.id == closing.id:
|
||
self._manual_active = False
|
||
self.run_btn.setText(tr("co4e.run"))
|
||
self._flows.pop(flow_idx)
|
||
self.flow_bar.blockSignals(True)
|
||
self.flow_bar.removeTab(idx)
|
||
self.flow_bar.blockSignals(False)
|
||
self._active_flow_idx = -1
|
||
if not self._flows:
|
||
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
|
||
else:
|
||
new_bar = min(idx, len(self._flows)) # clamp to the last flow tab
|
||
self.flow_bar.blockSignals(True)
|
||
self.flow_bar.setCurrentIndex(new_bar)
|
||
self.flow_bar.blockSignals(False)
|
||
self._on_flow_tab_changed(new_bar)
|
||
|
||
def _sync_active_flow_tab_text(self) -> None:
|
||
i = self.flow_bar.currentIndex()
|
||
if i >= 1: # never rename the Runs tab
|
||
self.flow_bar.setTabText(i, self._wf.name or tr("co4e.untitled"))
|
||
|
||
def _reflect_active_run(self, wf_id: str) -> None:
|
||
"""If a run for this flow is active, mirror its live node statuses onto the
|
||
canvas and keep tracking it so updates continue to show."""
|
||
for h in self.manager.all_runs():
|
||
if h.wf_id == wf_id and h.running:
|
||
self._flow_runs[wf_id] = h.id
|
||
for nid, st in h.node_status.items():
|
||
self.canvas.update_node_status(nid, st)
|
||
return
|
||
|
||
# ---- per-flow run helpers (parallel, isolated per flow) ---------------
|
||
def _cur_run_id(self) -> Optional[str]:
|
||
"""The active canvas run of the CURRENTLY-shown flow, or None. Clears a
|
||
stale entry if that run already finished."""
|
||
wf = getattr(self, "_wf", None)
|
||
if wf is None:
|
||
return None
|
||
rid = self._flow_runs.get(wf.id)
|
||
if rid is None:
|
||
return None
|
||
h = self.manager.get(rid)
|
||
if h is None or not h.running:
|
||
self._flow_runs.pop(wf.id, None)
|
||
return None
|
||
return rid
|
||
|
||
def _outputs_for(self, wf_id: str) -> Dict[str, str]:
|
||
"""This flow's accumulated step outputs (kept separate per flow so parallel
|
||
runs never seed each other's context)."""
|
||
return self._flow_outputs.setdefault(wf_id, {})
|
||
|
||
def _update_run_btn(self) -> None:
|
||
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
|
||
else tr("co4e.run"))
|
||
|
||
# ---- sidebar ----------------------------------------------------------
|
||
def _build_sidebar(self) -> QWidget:
|
||
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
|
||
# at once, so "what can I drag onto the canvas" is answered by looking
|
||
# rather than by clicking through three unlabeled tabs.
|
||
# A vertical splitter, not a fixed stack: on a short window four stacked
|
||
# lists otherwise squeeze down to one visible row each. The splitter
|
||
# hands out the available height by weight and lets the user re-balance
|
||
# it by dragging; each list keeps a small minimum so none disappears.
|
||
self._sections: dict = {}
|
||
self.sidebar = QWidget()
|
||
outer_col = QVBoxLayout(self.sidebar)
|
||
outer_col.setContentsMargins(6, 6, 6, 6)
|
||
outer_col.setSpacing(0)
|
||
self.side_split = QSplitter(Qt.Vertical)
|
||
self.side_split.setChildrenCollapsible(False)
|
||
self.side_split.setHandleWidth(8)
|
||
outer_col.addWidget(self.side_split, 1)
|
||
|
||
class _Col:
|
||
"""Adapter so the section builders below read the same as before."""
|
||
|
||
def __init__(self, split):
|
||
self._split = split
|
||
|
||
def addWidget(self, w, stretch=1):
|
||
self._split.addWidget(w)
|
||
self._split.setStretchFactor(self._split.count() - 1, stretch)
|
||
|
||
col = _Col(self.side_split)
|
||
|
||
# --- WORKFLOWS ---------------------------------------------------
|
||
self.wf_new_btn = QPushButton(tr("co4e.new"))
|
||
self.wf_new_btn.setIcon(icon("plus"))
|
||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||
self.wf_new_btn.setObjectName("co4eSectionAction")
|
||
self.wf_new_btn.setFlat(True)
|
||
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
|
||
self.wf_new_btn.clicked.connect(self._new_workflow)
|
||
wf_body = QWidget(); wl = QVBoxLayout(wf_body)
|
||
wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
|
||
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
|
||
# double-click loads it onto the canvas.
|
||
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
|
||
self.wf_list.setToolTip(tr("co4e.drag_hint"))
|
||
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
|
||
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
|
||
wl.addWidget(self.wf_list, 1)
|
||
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
|
||
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
|
||
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
|
||
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
|
||
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
|
||
wf_btns.addWidget(b)
|
||
wf_btns.addStretch(1)
|
||
wl.addLayout(wf_btns)
|
||
# Its own row: sharing one line with the three icon buttons cut "Chạy
|
||
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
|
||
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
|
||
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
|
||
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
|
||
wl.addWidget(self.wf_runbg_btn)
|
||
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
|
||
|
||
# --- AGENTS ------------------------------------------------------
|
||
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)
|
||
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)
|
||
|
||
# --- 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)
|
||
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)
|
||
|
||
# --- RUNS --------------------------------------------------------
|
||
# A short, always-visible view of the same runs the Flow Status page
|
||
# tables in full. Clicking one opens that page with the run selected.
|
||
# Icon only: the heading beside it already reads FLOW STATUS, and the
|
||
# label was long enough to be cut in half in a narrow sidebar.
|
||
self.runs_more_btn = QPushButton()
|
||
self.runs_more_btn.setIcon(icon("chevron-right"))
|
||
self.runs_more_btn.setFixedWidth(30)
|
||
self.runs_more_btn.setFlat(True)
|
||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
|
||
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
|
||
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
|
||
self.runs_side_list = QListWidget()
|
||
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
|
||
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
|
||
rl.addWidget(self.runs_side_list, 1)
|
||
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
|
||
# Small enough that all four still fit on a laptop screen, large enough
|
||
# that each shows more than a single row.
|
||
for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
|
||
lst.setMinimumHeight(56)
|
||
return self.sidebar
|
||
|
||
_SIDE_RUNS = 6
|
||
|
||
def _refresh_side_runs(self) -> None:
|
||
"""Mirror the newest runs into the sidebar's short list."""
|
||
lst = getattr(self, "runs_side_list", None)
|
||
if lst is None:
|
||
return
|
||
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
|
||
lst.clear()
|
||
for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
|
||
it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
|
||
f" {h.progress_text()}")
|
||
it.setData(Qt.UserRole, h.id)
|
||
it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
|
||
lst.addItem(it)
|
||
|
||
def _on_side_run_clicked(self, item) -> None:
|
||
"""Open the full Flow Status page with this run selected."""
|
||
run_id = item.data(Qt.UserRole)
|
||
self._show_runs(True)
|
||
for r in range(self.runs_table.rowCount()):
|
||
cell = self.runs_table.item(r, 0)
|
||
if cell is not None and cell.data(Qt.UserRole) == run_id:
|
||
self.runs_table.setCurrentCell(r, 0)
|
||
break
|
||
|
||
def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
|
||
stretch: int = 1) -> QWidget:
|
||
"""One named, foldable section of the sidebar column.
|
||
|
||
Replaces the three icon-only tabs: all the lists are visible at once
|
||
(WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
|
||
action that belongs to it. Clicking the heading folds the section, so a
|
||
narrow window can still get to everything.
|
||
"""
|
||
box = QWidget()
|
||
v = QVBoxLayout(box)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
v.setSpacing(2)
|
||
|
||
row = QHBoxLayout()
|
||
row.setContentsMargins(0, 0, 0, 0)
|
||
row.setSpacing(4)
|
||
head = QPushButton()
|
||
head.setObjectName("co4eSectionHdr")
|
||
head.setCheckable(True)
|
||
head.setChecked(True)
|
||
head.setCursor(Qt.PointingHandCursor)
|
||
head.setFlat(True)
|
||
head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
|
||
row.addWidget(head, 1)
|
||
if action is not None:
|
||
row.addWidget(action, 0)
|
||
v.addLayout(row)
|
||
v.addWidget(body, 1)
|
||
|
||
self._sections[key] = (head, body, stretch)
|
||
self._sync_section_arrow(key)
|
||
return box
|
||
|
||
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
|
||
"""Fold/unfold a section AND give its height back to the others.
|
||
|
||
Inside a splitter, hiding the body is not enough — the pane keeps its
|
||
share of the height, so folding would free nothing. Clamping the whole
|
||
section to its header height makes the splitter re-deal the space.
|
||
"""
|
||
body.setVisible(on)
|
||
if on:
|
||
box.setMaximumHeight(16777215)
|
||
else:
|
||
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
|
||
self._sync_section_arrow(key)
|
||
|
||
def _sync_section_arrow(self, key: str) -> None:
|
||
head, _body, _s = self._sections[key]
|
||
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
|
||
|
||
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
|
||
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
|
||
b.setFixedWidth(34)
|
||
b.clicked.connect(slot)
|
||
return b
|
||
|
||
def _reload_sidebar(self) -> None:
|
||
self.wf_list.clear()
|
||
for wf in co4e.list_workflows():
|
||
tag = tr("co4e.template") if wf.is_template else tr("co4e.saved")
|
||
it = QListWidgetItem(icon("workspaces"), f"{wf.name} · {tag}")
|
||
it.setData(Qt.UserRole, ("saved", wf.id))
|
||
it.setData(Qt.UserRole + 2, {"kind": "workflow", "workflow": co4e.workflow_to_dict(wf)})
|
||
self.wf_list.addItem(it)
|
||
# Agents: only the Parallel fan-out node + the user's own custom agents
|
||
# (create your own with "+ New agent"; drag onto the canvas). The blank
|
||
# "New Step" palette entry was removed — use the toolbar "+ Add" instead.
|
||
self.agent_list.clear()
|
||
self.agent_list.addItem(self._palette_item(
|
||
tr("co4e.parallel_node"), "server",
|
||
{"variant": "parallel", "label": "Parallel", "role": "PARALLEL", "icon": "server",
|
||
"sub_agents": []}))
|
||
for ca in co4e.list_custom_agents():
|
||
step = co4e.Step(label=ca.name, agent_slug=co4e.slugify(ca.name), role=ca.role or "AGENT",
|
||
icon=ca.icon, instructions=ca.instructions,
|
||
context=getattr(ca, "context", ""), model=ca.model,
|
||
permission_preset=ca.permission_preset, skills=list(ca.skills),
|
||
attachments=list(getattr(ca, "attachments", []) or []))
|
||
it = self._palette_item(f"{ca.name} · {ca.role} · {tr('co4e.custom')}", ca.icon or "robot",
|
||
co4e._step_dict(step))
|
||
it.setData(Qt.UserRole + 1, ca.id)
|
||
self.agent_list.addItem(it)
|
||
# Skills
|
||
self.skill_list.clear()
|
||
for name in _skill_names():
|
||
content = skills_mod.skill_prefix_for(name)
|
||
payload = co4e._step_dict(co4e.Step(
|
||
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
|
||
instructions=content, skills=[name]))
|
||
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
|
||
|
||
@staticmethod
|
||
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
|
||
it = QListWidgetItem(icon(icon_name), text)
|
||
it.setData(Qt.UserRole, payload)
|
||
return it
|
||
|
||
# ---- center -----------------------------------------------------------
|
||
def _build_center(self) -> QWidget:
|
||
from PySide6.QtWidgets import QStackedWidget, QTabBar
|
||
page = QWidget()
|
||
lay = QVBoxLayout(page)
|
||
|
||
# Flow tab bar: a pinned "Runs" tab first (manage every flow run), then a
|
||
# browser-style tab per open flow — each keeps its own graph (no mixing).
|
||
self.flow_bar = QTabBar()
|
||
self.flow_bar.setObjectName("flowTabs")
|
||
self.flow_bar.setTabsClosable(True)
|
||
self.flow_bar.setMovable(True)
|
||
self.flow_bar.setExpanding(False)
|
||
self.flow_bar.setDrawBase(False)
|
||
# No arrow scroll buttons — when the tabs overflow they scroll inside a
|
||
# frameless horizontal scroller you drag left/right (see flow_row below).
|
||
self.flow_bar.setUsesScrollButtons(False)
|
||
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
|
||
# flush, centred). Here we only style the per-tab close (✕) button, which
|
||
# QTabBar places centred on the tab's right (see _add_tab_close_button).
|
||
_fp = current_palette()
|
||
self.flow_bar.setStyleSheet(
|
||
"QPushButton#flowTabClose {"
|
||
f" border: none; background: transparent; color: {_fp.text_muted};"
|
||
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
|
||
f" border-radius: {_fp.radius_sm}px; }}"
|
||
"QPushButton#flowTabClose:hover {"
|
||
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
|
||
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
|
||
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
|
||
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
|
||
self.flow_bar.tabCloseRequested.connect(self._close_flow_tab)
|
||
# "+" new-flow button styled as the last tab in the strip (browser-style)
|
||
# — the + glyph sits inside a tab-shaped button flush with the tabs.
|
||
self.flow_add_btn = QPushButton("+")
|
||
self.flow_add_btn.setObjectName("flowAddBtn")
|
||
self.flow_add_btn.setFixedWidth(34)
|
||
self.flow_add_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||
self.flow_add_btn.clicked.connect(self._new_workflow)
|
||
# Frameless horizontal scroller around the tab strip: overflowing tabs
|
||
# scroll (drag) left/right instead of being boxed with arrow buttons.
|
||
# The tab bar AND the "+" button are pinned to the SAME fixed height —
|
||
# giving the scroll area extra height for its scrollbar (as a previous
|
||
# version did) left the tabs top-anchored inside a taller box while the
|
||
# "+" button centered across that whole (taller) box, so the two drifted
|
||
# out of alignment. Same height on both = always aligned, no centering
|
||
# math needed; the scrollbar only appears on overflow (rare) and briefly
|
||
# overlaps the tab strip's bottom edge in that case.
|
||
_tab_h = self.flow_bar.sizeHint().height()
|
||
self.flow_bar.setFixedHeight(_tab_h)
|
||
self.flow_add_btn.setFixedHeight(_tab_h)
|
||
self.flow_scroll = QScrollArea()
|
||
self.flow_scroll.setObjectName("flowTabScroll")
|
||
self.flow_scroll.setWidget(self.flow_bar)
|
||
self.flow_scroll.setWidgetResizable(True)
|
||
self.flow_scroll.setFrameShape(QScrollArea.NoFrame) # no outer frame
|
||
self.flow_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
self.flow_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.flow_scroll.setFixedHeight(_tab_h)
|
||
self.flow_scroll.setStyleSheet(
|
||
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
|
||
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
|
||
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
|
||
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
|
||
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
|
||
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
|
||
# The strip itself is NOT shown any more (see class docstring): flows are
|
||
# picked from the WORKFLOWS list on the left, one open at a time. The
|
||
# QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
|
||
# every open/close/rename path already goes through it — but the user
|
||
# never sees or drives it.
|
||
self.flow_scroll.setVisible(False)
|
||
self.flow_add_btn.setVisible(False)
|
||
|
||
# Content switches between the Runs table (tab 0) and the flow editor.
|
||
self.center_stack = QStackedWidget()
|
||
lay.addWidget(self.center_stack, 1)
|
||
self.center_stack.addWidget(self._build_runs_page()) # stack 0 = Runs
|
||
|
||
flow_page = QWidget()
|
||
lay = QVBoxLayout(flow_page)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
|
||
bar = QHBoxLayout(); bar.setSpacing(5)
|
||
self.name_edit = QLineEdit(self._wf.name)
|
||
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
|
||
self.name_edit.textChanged.connect(self._on_name_changed)
|
||
# "Add" is a labelled button (not a "+" icon) so it isn't mistaken for
|
||
# the zoom-in control, which now lives in the canvas's bottom-left overlay.
|
||
self.add_step_btn = QPushButton(tr("co4e.add")); self.add_step_btn.setIcon(icon("plus"))
|
||
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
|
||
self.add_step_btn.clicked.connect(self._add_blank_step)
|
||
self.save_btn = QPushButton(tr("co4e.save")); self.save_btn.setIcon(icon("save"))
|
||
self.save_btn.setObjectName("primary")
|
||
self.save_btn.setToolTip(tr("co4e.tt_save"))
|
||
self.save_btn.clicked.connect(lambda: self._save(as_template=False))
|
||
self.save_tpl_btn = self._icon_btn("star", "co4e.tt_save_template",
|
||
lambda: self._save(as_template=True))
|
||
self.mode_combo = QComboBox()
|
||
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
|
||
for m in co4e.RUN_MODES:
|
||
self.mode_combo.addItem(tr(f"co4e.mode.{m}"), m)
|
||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||
self.run_btn = QPushButton(tr("co4e.run")); self.run_btn.setIcon(icon("play"))
|
||
self.run_btn.setObjectName("primary")
|
||
self.run_btn.setToolTip(tr("co4e.tt_run"))
|
||
self.run_btn.clicked.connect(self._on_run_clicked)
|
||
|
||
# The pinned "Runs" tab lost its strip, so it becomes a toggle here —
|
||
# one click to the run table and one click back, from either page.
|
||
self.runs_btn = QPushButton(tr("co4e.runs_tab"))
|
||
self.runs_btn.setIcon(icon("monitoring"))
|
||
self.runs_btn.setCheckable(True)
|
||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||
self.runs_btn.toggled.connect(self._show_runs)
|
||
|
||
bar.addWidget(QLabel(tr("co4e.flow_name")))
|
||
bar.addWidget(self.name_edit, 1)
|
||
bar.addWidget(self.add_step_btn)
|
||
bar.addWidget(self.save_btn)
|
||
bar.addWidget(self.save_tpl_btn)
|
||
bar.addWidget(self.mode_combo)
|
||
bar.addWidget(self.run_btn)
|
||
bar.addWidget(self.runs_btn)
|
||
lay.addLayout(bar)
|
||
|
||
self.canvas = Co4ECanvas()
|
||
self._build_canvas_overlay()
|
||
vsplit = QSplitter(Qt.Vertical)
|
||
vsplit.addWidget(self.canvas)
|
||
chat_widget = self._build_chat() # default-collapsed (see _build_chat)
|
||
vsplit.addWidget(chat_widget)
|
||
vsplit.setStretchFactor(0, 1)
|
||
self._vsplit = vsplit # so the message panel can collapse/expand
|
||
# Messages start collapsed — give the canvas the room from the start,
|
||
# not the [540, 220] split that assumed an expanded chat box.
|
||
collapsed_h = chat_widget.maximumHeight()
|
||
vsplit.setSizes([max(0, 760 - collapsed_h), collapsed_h])
|
||
lay.addWidget(vsplit, 1)
|
||
self.center_stack.addWidget(flow_page) # stack 1 = flow editor
|
||
self.center_stack.setCurrentIndex(1)
|
||
return page
|
||
|
||
def _build_runs_page(self) -> QWidget:
|
||
"""The pinned 'Runs' tab: a table of every flow run (name · status · steps
|
||
done/total · creator · created) for tracking. Double-click a run to open
|
||
that flow's tab with its live status."""
|
||
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"))
|
||
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.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.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.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.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.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.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
|
||
|
||
def _wrap_config(self) -> QWidget:
|
||
"""Wrap the step-config panel with a header that has an expand/collapse
|
||
toggle, so it can be folded away to give the canvas more room."""
|
||
container = QWidget()
|
||
container.setObjectName("configContainer")
|
||
v = QVBoxLayout(container)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
v.setSpacing(0)
|
||
header = QWidget()
|
||
hb = QHBoxLayout(header)
|
||
hb.setContentsMargins(4, 3, 4, 3)
|
||
hb.setSpacing(4)
|
||
self.config_toggle_btn = QPushButton()
|
||
self.config_toggle_btn.setIcon(icon("chevron-right"))
|
||
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
|
||
self.config_toggle_btn.setFixedSize(26, 24)
|
||
self.config_toggle_btn.clicked.connect(self._toggle_config)
|
||
self.config_title = QLabel(tr("co4e.config_title"))
|
||
self.config_title.setObjectName("hint")
|
||
hb.addWidget(self.config_toggle_btn)
|
||
hb.addWidget(self.config_title, 1)
|
||
v.addWidget(header)
|
||
v.addWidget(self.config, 1)
|
||
self._cfg_vlayout = v
|
||
# Spacers used ONLY while collapsed, to keep the lone toggle icon
|
||
# vertically CENTERED in the thin strip (its position no longer jumps to
|
||
# the top after collapsing).
|
||
self._cfg_top_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||
self._cfg_bot_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||
self.config_container = container
|
||
return container
|
||
|
||
# Below this window width the three panes (rail + 180 sidebar + canvas +
|
||
# 300 config) leave the canvas too little to draw a flow in, and the config
|
||
# fields start clipping instead of shrinking. Measured with
|
||
# tools/check_responsive.py — Co4E gets the full content area (no project or
|
||
# history pane beside it), so the threshold is about its own screen only.
|
||
_NARROW = 1300
|
||
|
||
def showEvent(self, e): # noqa: N802 - Qt override
|
||
super().showEvent(e)
|
||
self._narrow_guard.attach()
|
||
|
||
def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401
|
||
"""Fold the step-config panel on a narrow window, restore it when there
|
||
is room again.
|
||
|
||
Attached from __init__ rather than only on show: this page sits inside a
|
||
QTabWidget, whose minimum width is the MAXIMUM over all its pages —
|
||
including hidden ones. While Co4E sat unfolded in the background it was
|
||
forcing Project and Cowork to be ~1180px wide too.
|
||
"""
|
||
if narrow != self._config_collapsed:
|
||
self._toggle_config()
|
||
|
||
def _toggle_config(self) -> None:
|
||
self._config_collapsed = not self._config_collapsed
|
||
v = self._cfg_vlayout
|
||
if self._config_collapsed:
|
||
w = self.config_container.width()
|
||
if w > 60:
|
||
self._config_expanded_w = w
|
||
self.config.hide()
|
||
self.config_title.hide()
|
||
self.config_container.setMaximumWidth(34)
|
||
self.config_toggle_btn.setIcon(icon("chevron-left"))
|
||
self.config_toggle_btn.setToolTip(tr("co4e.tt_expand_config"))
|
||
# center the toggle vertically in the collapsed strip
|
||
v.insertItem(0, self._cfg_top_spacer)
|
||
v.addItem(self._cfg_bot_spacer)
|
||
# A maximumWidth alone doesn't make the splitter hand the freed width
|
||
# to the canvas — set sizes explicitly so the panel folds to the right.
|
||
sizes = self._split.sizes()
|
||
if len(sizes) == 3:
|
||
freed = sizes[2] - 34
|
||
sizes[2] = 34
|
||
sizes[1] = max(200, sizes[1] + freed)
|
||
self._split.setSizes(sizes)
|
||
# Without this the splitter keeps reporting the OLD minimum width,
|
||
# and since a QTabWidget's minimum is the maximum over all its pages
|
||
# — hidden ones included — Co4E would go on forcing Project and
|
||
# Cowork to be 1180px wide even while folded here.
|
||
self._refresh_min_width()
|
||
else:
|
||
v.removeItem(self._cfg_top_spacer)
|
||
v.removeItem(self._cfg_bot_spacer)
|
||
self.config_container.setMaximumWidth(16777215)
|
||
self.config.show()
|
||
self.config_title.show()
|
||
self.config_toggle_btn.setIcon(icon("chevron-right"))
|
||
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
|
||
sizes = self._split.sizes()
|
||
if len(sizes) == 3:
|
||
want = self._config_expanded_w
|
||
delta = want - sizes[2]
|
||
sizes[2] = want
|
||
sizes[1] = max(200, sizes[1] - delta)
|
||
self._split.setSizes(sizes)
|
||
self._refresh_min_width()
|
||
|
||
def _refresh_min_width(self) -> None:
|
||
"""Make the splitter (and everything above it) re-read its minimum."""
|
||
self.config_container.updateGeometry()
|
||
self._split.refresh()
|
||
self._split.updateGeometry()
|
||
self.updateGeometry()
|
||
|
||
def _build_canvas_overlay(self) -> None:
|
||
"""Zoom +/− and Fit as a small floating control at the canvas's
|
||
bottom-left, stacked vertically. The frame is transparent (so it follows
|
||
the dark/light theme — only the buttons carry a themed background) and the
|
||
buttons are half-size."""
|
||
from PySide6.QtCore import QSize
|
||
|
||
bar = QFrame()
|
||
bar.setObjectName("canvasOverlay")
|
||
bar.setStyleSheet("QFrame#canvasOverlay { background: transparent; border: none; }")
|
||
v = QVBoxLayout(bar)
|
||
v.setContentsMargins(2, 2, 2, 2)
|
||
v.setSpacing(3)
|
||
self.zoom_in_btn = self._icon_btn("plus", "co4e.tt_zoom_in", lambda: self.canvas.zoom_in())
|
||
self.zoom_out_btn = self._icon_btn("minus", "co4e.tt_zoom_out", lambda: self.canvas.zoom_out())
|
||
self.fit_btn = self._icon_btn("search", "co4e.fit_tooltip", lambda: self.canvas.fit_view())
|
||
for b in (self.zoom_in_btn, self.zoom_out_btn, self.fit_btn):
|
||
b.setFixedSize(16, 16) # ~half the previous size
|
||
b.setIconSize(QSize(11, 11))
|
||
b.setStyleSheet("QPushButton { padding: 0px; }") # keep themed bg, drop padding
|
||
v.addWidget(b)
|
||
self.canvas.add_overlay(bar)
|
||
|
||
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)
|
||
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._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.submit.connect(self._chat_send)
|
||
self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send"))
|
||
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_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
|
||
|
||
def _toggle_messages(self) -> None:
|
||
"""Show/hide the WHOLE chat box (message list + composer) below the
|
||
header. Collapsing hands the freed height to the canvas.
|
||
|
||
A QSplitter's ``setMaximumHeight`` on one side does NOT automatically
|
||
redistribute the freed space to the other side — it just shrinks the
|
||
splitter's own total height, leaving the canvas frozen at its old size
|
||
and blank space below it. So this explicitly calls ``setSizes`` on both
|
||
the collapse AND the expand path, computed from the splitter's CURRENT
|
||
total (not a hardcoded guess) — that total stays constant; only how
|
||
it's split between canvas/chat changes."""
|
||
self._msgs_collapsed = not self._msgs_collapsed
|
||
collapsed_h = self._mhdr.sizeHint().height() + 6
|
||
if self._msgs_collapsed:
|
||
if hasattr(self, "_vsplit"):
|
||
self._vsplit_sizes = self._vsplit.sizes() # remember to restore
|
||
self.chat_stack.hide()
|
||
self.chat_input_row.hide()
|
||
self._chat_widget.setMaximumHeight(collapsed_h)
|
||
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → click to expand
|
||
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
|
||
if hasattr(self, "_vsplit"):
|
||
total = sum(self._vsplit.sizes()) or (self._vsplit_sizes and sum(self._vsplit_sizes)) or 760
|
||
self._vsplit.setSizes([max(0, total - collapsed_h), collapsed_h])
|
||
else:
|
||
self._chat_widget.setMaximumHeight(16777215)
|
||
self.chat_stack.show()
|
||
self.chat_input_row.show()
|
||
self.chat_toggle_btn.setIcon(icon("chevron-down")) # expanded → click to collapse
|
||
self.chat_toggle_btn.setToolTip(tr("co4e.tt_collapse_msgs"))
|
||
if getattr(self, "_vsplit_sizes", None) and hasattr(self, "_vsplit"):
|
||
self._vsplit.setSizes(self._vsplit_sizes)
|
||
return
|
||
|
||
# ---- per-flow chat logs (each flow = its own conversation) ------------
|
||
def _ensure_flow_log(self, wf_id: str) -> ChatView:
|
||
"""The ChatView for a flow, created + added to the stack on first use so
|
||
each flow tab keeps a SEPARATE conversation."""
|
||
log = self._flow_logs.get(wf_id)
|
||
if log is None:
|
||
log = ChatView()
|
||
log._co4e_plan_bubble = None # per-flow 'current plan' bubble
|
||
self._flow_logs[wf_id] = log
|
||
self.chat_stack.addWidget(log)
|
||
return log
|
||
|
||
def _active_log(self) -> ChatView:
|
||
wf = getattr(self, "_wf", None)
|
||
return self._ensure_flow_log(wf.id if wf is not None else "__none__")
|
||
|
||
@property
|
||
def chat_log(self) -> ChatView:
|
||
"""The conversation of the CURRENTLY-shown flow (all append/stream calls
|
||
go here). Assignment is not supported — logs are per-flow now."""
|
||
return self._active_log()
|
||
|
||
@property
|
||
def _plan_bubble(self):
|
||
return getattr(self._active_log(), "_co4e_plan_bubble", None)
|
||
|
||
@_plan_bubble.setter
|
||
def _plan_bubble(self, value) -> None:
|
||
self._active_log()._co4e_plan_bubble = value
|
||
|
||
# ---- workflow load/save ----------------------------------------------
|
||
def _apply_workflow(self, wf: co4e.Workflow) -> None:
|
||
self._wf = wf
|
||
# Per-flow outputs are kept in self._flow_outputs[wf.id] — do NOT clear
|
||
# here (switching tabs must not wipe another flow's accumulated context).
|
||
# Switch the visible conversation to THIS flow's own log.
|
||
self.chat_stack.setCurrentWidget(self._ensure_flow_log(wf.id))
|
||
self.name_edit.setText(wf.name)
|
||
self.canvas.load(wf.nodes, wf.edges)
|
||
self.config.clear_step()
|
||
if wf.nodes:
|
||
self.canvas.relayout_if_vertical() # convert old top-down flows to left→right
|
||
self.canvas.fit_view()
|
||
self._update_run_btn() # reflect THIS flow's run state
|
||
self._refresh_usage_total() # show THIS flow's token/cost total
|
||
|
||
def _new_workflow(self) -> None:
|
||
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
|
||
# Pressing this while the canvas already holds an empty untitled flow
|
||
# produced an identical empty untitled flow — correct, and completely
|
||
# invisible, so the button read as broken. Say what happened and put the
|
||
# cursor where the next thing to do is: naming it.
|
||
self.name_edit.setFocus()
|
||
self.name_edit.selectAll()
|
||
self.status_message.emit(tr("co4e.new_flow_ready"))
|
||
|
||
def _selected_wf(self) -> Optional[co4e.Workflow]:
|
||
"""Materialise the selected saved-flow row into a Workflow."""
|
||
item = self.wf_list.currentItem()
|
||
if item is None:
|
||
return None
|
||
_kind, ident = item.data(Qt.UserRole)
|
||
return co4e.get_workflow(ident)
|
||
|
||
def _load_selected_workflow(self, *_a) -> None:
|
||
wf = self._selected_wf()
|
||
if wf is not None:
|
||
self._open_flow(wf) # open (or focus) its browser-style tab
|
||
|
||
def _edit_selected_workflow(self) -> None:
|
||
wf = self._selected_wf()
|
||
if wf is None:
|
||
self.status_message.emit(tr("co4e.select_flow"))
|
||
return
|
||
self._open_flow(wf)
|
||
|
||
def _duplicate_selected_workflow(self) -> None:
|
||
wf = self._selected_wf()
|
||
if wf is None:
|
||
self.status_message.emit(tr("co4e.select_flow"))
|
||
return
|
||
dup = co4e.duplicate_workflow(wf)
|
||
self._reload_sidebar()
|
||
self.status_message.emit(tr("co4e.duplicated_msg", name=dup.name))
|
||
|
||
def _wf_context_menu(self, pos) -> None:
|
||
lw = self.wf_list
|
||
item = lw.itemAt(pos)
|
||
if item is None:
|
||
return
|
||
lw.setCurrentItem(item)
|
||
_kind, ident = item.data(Qt.UserRole)
|
||
menu = QMenu(lw)
|
||
act_edit = menu.addAction(icon("edit"), tr("co4e.edit"))
|
||
act_rename = menu.addAction(icon("edit"), tr("co4e.rename"))
|
||
act_dup = menu.addAction(icon("branch"), tr("co4e.duplicate"))
|
||
act_run = menu.addAction(icon("play"), tr("co4e.run_bg"))
|
||
act_del = menu.addAction(icon("trash"), tr("co4e.delete"))
|
||
chosen = menu.exec(lw.viewport().mapToGlobal(pos))
|
||
if chosen is act_edit:
|
||
self._edit_selected_workflow()
|
||
elif chosen is act_rename:
|
||
self._rename_workflow(ident)
|
||
elif chosen is act_dup:
|
||
self._duplicate_selected_workflow()
|
||
elif chosen is act_run:
|
||
self._run_selected_in_background()
|
||
elif chosen is act_del:
|
||
self._delete_selected_workflow()
|
||
|
||
def _rename_workflow(self, ident: str) -> None:
|
||
"""Rename a saved flow in place (e.g. to match its function/task)."""
|
||
wf = co4e.get_workflow(ident)
|
||
if wf is None:
|
||
return
|
||
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
|
||
text=wf.name)
|
||
name = (name or "").strip()
|
||
if not ok or not name:
|
||
return
|
||
wf.name = name
|
||
co4e.save_workflow(wf)
|
||
if self._wf.id == ident:
|
||
self.name_edit.setText(name)
|
||
self._wf.name = name
|
||
self._reload_sidebar()
|
||
self.status_message.emit(tr("co4e.renamed_msg", name=name))
|
||
|
||
def _delete_selected_workflow(self) -> None:
|
||
item = self.wf_list.currentItem()
|
||
if item is None:
|
||
return
|
||
_kind, ident = item.data(Qt.UserRole)
|
||
co4e.delete_workflow(ident)
|
||
self._reload_sidebar()
|
||
|
||
def _sync_wf_from_canvas(self) -> None:
|
||
self._wf.nodes = self.canvas.nodes()
|
||
self._wf.edges = self.canvas.edges()
|
||
self._wf.name = self.name_edit.text().strip() or tr("co4e.untitled")
|
||
|
||
def _save(self, as_template: bool) -> None:
|
||
self._sync_wf_from_canvas()
|
||
self._wf.is_template = as_template
|
||
co4e.save_workflow(self._wf)
|
||
self._reload_sidebar()
|
||
self.status_message.emit(tr("co4e.saved_msg", name=self._wf.name))
|
||
|
||
def _autosave(self) -> None:
|
||
if co4e.get_workflow(self._wf.id) is not None:
|
||
self._sync_wf_from_canvas()
|
||
co4e.save_workflow(self._wf)
|
||
|
||
def _on_name_changed(self, text: str) -> None:
|
||
self._wf.name = text.strip() or tr("co4e.untitled")
|
||
self._sync_active_flow_tab_text()
|
||
|
||
def _add_blank_step(self) -> None:
|
||
self.canvas.add_palette_step(co4e.Step(label="New Step"),
|
||
self.canvas.mapToScene(self.canvas.rect().center()))
|
||
|
||
# ---- node selection / config -----------------------------------------
|
||
def _on_node_selected(self, node_id: str) -> None:
|
||
for n in self.canvas.nodes():
|
||
if n.id == node_id:
|
||
self.config.load_step(node_id, n.data, _skill_names())
|
||
if self._config_collapsed:
|
||
self._toggle_config()
|
||
return
|
||
|
||
def _on_config_changed(self) -> None:
|
||
for n in self.canvas.nodes():
|
||
self.canvas.refresh_node(n.id)
|
||
self._autosave()
|
||
|
||
# ---- custom agents ----------------------------------------------------
|
||
def _new_agent(self) -> None:
|
||
self._edit_agent_dialog(co4e.new_custom_agent(""))
|
||
|
||
def _edit_agent(self) -> None:
|
||
item = self.agent_list.currentItem()
|
||
cid = item.data(Qt.UserRole + 1) if item else None
|
||
if not cid:
|
||
self.status_message.emit(tr("co4e.select_custom_agent"))
|
||
return
|
||
agent = next((a for a in co4e.list_custom_agents() if a.id == cid), None)
|
||
if agent is not None:
|
||
self._edit_agent_dialog(agent)
|
||
|
||
def _edit_agent_dialog(self, agent: co4e.CustomAgent) -> None:
|
||
from .co4e_agent_dialog import Co4EAgentDialog
|
||
|
||
dlg = Co4EAgentDialog(self.ctx, agent, _skill_names(), self)
|
||
if dlg.exec():
|
||
co4e.save_custom_agent(dlg.result_agent())
|
||
self._reload_sidebar()
|
||
|
||
def _delete_agent(self) -> None:
|
||
item = self.agent_list.currentItem()
|
||
cid = item.data(Qt.UserRole + 1) if item else None
|
||
if not cid:
|
||
self.status_message.emit(tr("co4e.select_custom_agent"))
|
||
return
|
||
co4e.delete_custom_agent(cid)
|
||
self._reload_sidebar()
|
||
|
||
def _manage_skills(self) -> None:
|
||
from .skills_dialog import SkillsDialog
|
||
|
||
SkillsDialog(self, self.ctx).exec()
|
||
self._reload_sidebar()
|
||
|
||
# ---- running ----------------------------------------------------------
|
||
def _skill_map(self) -> Dict[str, str]:
|
||
out = {}
|
||
for name in _skill_names():
|
||
block = skills_mod.skill_prefix_for(name)
|
||
if block:
|
||
out[name] = block.split("\n", 1)[1] if "\n" in block else block
|
||
return out
|
||
|
||
def _current_mode(self) -> str:
|
||
return self.mode_combo.currentData() or "auto"
|
||
|
||
def _on_mode_changed(self, *_a) -> None:
|
||
# switching mode resets any in-progress manual sequence
|
||
self._manual_active = False
|
||
self._manual_order = []
|
||
self._manual_idx = 0
|
||
if self._cur_run_id() is None:
|
||
self.run_btn.setText(tr("co4e.run"))
|
||
|
||
def _on_run_clicked(self) -> None:
|
||
# THIS flow's run is active → interrupt it (other flows keep running).
|
||
cur = self._cur_run_id()
|
||
if cur is not None:
|
||
self.manager.stop(cur)
|
||
return
|
||
mode = self._current_mode()
|
||
if mode == "manual":
|
||
self._manual_run_or_advance()
|
||
else:
|
||
self._start_canvas_run(plan_mode=(mode == "plan"))
|
||
|
||
def _start_canvas_run(self, *, plan_mode: bool, only: Optional[set] = None,
|
||
seed: Optional[Dict[str, str]] = None) -> None:
|
||
self._sync_wf_from_canvas()
|
||
if not self._wf.nodes:
|
||
self.status_message.emit(tr("co4e.no_steps"))
|
||
return
|
||
wf_id = self._wf.id
|
||
if only is None:
|
||
self.canvas.reset_statuses()
|
||
self._outputs_for(wf_id).clear()
|
||
self._plan_bubble = None
|
||
self._append_chat("system", tr("co4e.run_started", name=self._wf.name))
|
||
run_id = self.manager.start(
|
||
self._wf, skill_map=self._skill_map(), plan_mode=plan_mode,
|
||
only_nodes=only, seed_outputs=seed or dict(self._outputs_for(wf_id)))
|
||
self._flow_runs[wf_id] = run_id # track THIS flow's run (parallel-safe)
|
||
self._run_logs[run_id] = self.chat_log # route its events to THIS flow's log
|
||
self.run_btn.setText(tr("co4e.interrupt"))
|
||
|
||
def _run_single(self, node_id: str) -> None:
|
||
"""Run one step (config panel "Run this step") with upstream context."""
|
||
if self._cur_run_id() is not None:
|
||
return
|
||
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
|
||
only={node_id}, seed=dict(self._outputs_for(self._wf.id)))
|
||
|
||
def _run_from(self, node_id: str) -> None:
|
||
if self._cur_run_id() is not None:
|
||
return
|
||
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
|
||
only=self._downstream(node_id), seed=dict(self._outputs_for(self._wf.id)))
|
||
|
||
def _downstream(self, node_id: str) -> set:
|
||
adj: Dict[str, List[str]] = {}
|
||
for e in self.canvas.edges():
|
||
adj.setdefault(e.source, []).append(e.target)
|
||
seen, stack = set(), [node_id]
|
||
while stack:
|
||
cur = stack.pop()
|
||
if cur in seen:
|
||
continue
|
||
seen.add(cur)
|
||
stack.extend(adj.get(cur, []))
|
||
return seen
|
||
|
||
# ---- manual mode (step-by-step) --------------------------------------
|
||
def _manual_run_or_advance(self) -> None:
|
||
if not self._manual_active:
|
||
self._sync_wf_from_canvas()
|
||
if not self._wf.nodes:
|
||
self.status_message.emit(tr("co4e.no_steps"))
|
||
return
|
||
self.canvas.reset_statuses()
|
||
self._outputs_for(self._wf.id).clear()
|
||
self._plan_bubble = None
|
||
self._manual_order = self._topo_order()
|
||
self._manual_idx = 0
|
||
self._manual_active = True
|
||
self._append_chat("system", tr("co4e.manual_started", name=self._wf.name))
|
||
self._manual_step()
|
||
|
||
def _manual_step(self) -> None:
|
||
if self._manual_idx >= len(self._manual_order):
|
||
self._manual_active = False
|
||
self.run_btn.setText(tr("co4e.run"))
|
||
self._append_chat("system", tr("co4e.run_done"))
|
||
return
|
||
nid = self._manual_order[self._manual_idx]
|
||
label = next((n.data.label for n in self.canvas.nodes() if n.id == nid), nid)
|
||
self._append_chat("system", tr("co4e.manual_step",
|
||
i=self._manual_idx + 1, n=len(self._manual_order), label=label))
|
||
run_id = self.manager.start(
|
||
self._wf, skill_map=self._skill_map(),
|
||
plan_mode=False, only_nodes={nid}, seed_outputs=dict(self._outputs_for(self._wf.id)),
|
||
manual=True)
|
||
self._flow_runs[self._wf.id] = run_id
|
||
self._run_logs[run_id] = self.chat_log
|
||
self.run_btn.setText(tr("co4e.interrupt"))
|
||
|
||
def _topo_order(self) -> List[str]:
|
||
nodes = self.canvas.nodes()
|
||
edges = self.canvas.edges()
|
||
waves = co4e.compute_waves(nodes, edges)
|
||
y = {n.id: n.y for n in nodes}
|
||
return sorted((n.id for n in nodes), key=lambda nid: (waves.get(nid, 0), y.get(nid, 0)))
|
||
|
||
# ---- run-manager events ----------------------------------------------
|
||
def _on_manager_event(self, run_id: str, ev: dict) -> None:
|
||
# Per-flow routing: every run's events go to ITS OWN flow log (so parallel
|
||
# runs never mix), and the canvas mirrors ONLY the run whose flow is the
|
||
# one currently shown. Flow Status refreshes on its own via `changed`.
|
||
h = self.manager.get(run_id)
|
||
run_wf = h.wf_id if h is not None else None
|
||
log = self._run_logs.get(run_id) or self.chat_log
|
||
shown = getattr(self, "_wf", None) is not None and run_wf == self._wf.id
|
||
t = ev.get("type")
|
||
if t == "node_status":
|
||
if shown:
|
||
self.canvas.update_node_status(ev.get("node_id"), ev.get("status"))
|
||
elif t == "node_output":
|
||
if run_wf is not None:
|
||
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
|
||
label = ev["node_id"]
|
||
if shown:
|
||
label = next((n.data.label for n in self.canvas.nodes() if n.id == ev["node_id"]),
|
||
ev["node_id"])
|
||
elif h is not None and h.wf is not None:
|
||
label = next((n.data.label for n in h.wf.nodes if n.id == ev["node_id"]), ev["node_id"])
|
||
if ev.get("output"):
|
||
bub = self._append_chat("assistant", f"**{label}**\n\n{ev['output']}", log=log)
|
||
# Per-message token/cost footer (↓in ↑out ▤ctx $cost), like Cowork.
|
||
self._apply_usage(bub, run_wf, ev.get("usage"))
|
||
elif t == "node_diff":
|
||
self._append_diff(ev.get("title", ""), ev.get("diff", ""), log=log)
|
||
elif t == "node_plan":
|
||
self._append_plan(ev.get("steps") or [], log=log)
|
||
elif t == "node_tool":
|
||
if not ev.get("ok", True):
|
||
# A single failed tool call isn't a step failure — the agent is told
|
||
# to recover and continue, so show it as a neutral notice (not a red
|
||
# "Error" that reads like the whole flow crashed).
|
||
self._append_chat("system", "⚠ " + tr("co4e.tool_failed", name=ev.get("name", "")), log=log)
|
||
elif t in ("run_done", "run_error"):
|
||
# Drop THIS flow's run tracking (other flows keep running in parallel).
|
||
if run_wf is not None and self._flow_runs.get(run_wf) == run_id:
|
||
self._flow_runs.pop(run_wf, None)
|
||
self._run_logs.pop(run_id, None)
|
||
if self._manual_active and shown:
|
||
self._manual_idx += 1
|
||
self._manual_step()
|
||
else:
|
||
if shown:
|
||
self.run_btn.setText(tr("co4e.run"))
|
||
self._append_chat("system", tr("co4e.run_done"), log=log)
|
||
# Clickable link to the output folder so files are one click away.
|
||
out = (h.out_dir if h is not None and h.out_dir else "") or str(self._flow_output_root())
|
||
try:
|
||
log.add_folder_link(out, tr("co4e.open_output_link"))
|
||
log.scroll_to_bottom()
|
||
except Exception: # noqa: BLE001 - link is a nicety, never fatal
|
||
pass
|
||
self._notify_run_finished(run_id) # popup: the flow finished
|
||
if not shown and h is not None:
|
||
self.status_message.emit(tr("co4e.bg_done", name=h.name, status=h.status))
|
||
|
||
def _notify_run_finished(self, run_id: str) -> None:
|
||
"""Show a non-blocking popup when a flow finishes (done / error / stopped),
|
||
so the user is notified even if they're on another screen."""
|
||
h = self.manager.get(run_id)
|
||
if h is None:
|
||
return
|
||
from PySide6.QtWidgets import QMessageBox
|
||
|
||
if not hasattr(self, "_run_popups"):
|
||
self._run_popups = []
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Warning if h.status == "error" else QMessageBox.Information)
|
||
box.setWindowTitle(tr("co4e.run_done_title"))
|
||
box.setText(tr("co4e.run_done_popup", name=h.name,
|
||
status=tr("co4e.status." + h.status)))
|
||
box.setStandardButtons(QMessageBox.Ok)
|
||
box.setModal(False) # non-blocking notification
|
||
box.setAttribute(Qt.WA_DeleteOnClose, True)
|
||
box.finished.connect(
|
||
lambda _r=0, b=box: self._run_popups.remove(b) if b in self._run_popups else None)
|
||
self._run_popups.append(box) # keep a ref so it isn't GC'd
|
||
box.show()
|
||
|
||
def _refresh_runs(self) -> None:
|
||
# Rebuild the always-fresh Runs table from the manager (single source of truth).
|
||
if not hasattr(self, "runs_table"):
|
||
return
|
||
p = current_palette()
|
||
color = {"running": p.accent, "done": p.success, "error": p.danger,
|
||
"stopped": p.text_muted}
|
||
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
|
||
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
|
||
# chronological insertion order, so reverse it for display).
|
||
runs = list(reversed(self.manager.runs()))
|
||
t = self.runs_table
|
||
# Preserve the selected run across the rebuild by its id (row indices shift
|
||
# as runs are added/deleted, so a row-index restore would jump).
|
||
sel_item = t.item(t.currentRow(), 0) if t.currentRow() >= 0 else None
|
||
sel_id = sel_item.data(Qt.UserRole) if sel_item is not None else None
|
||
t.setRowCount(len(runs))
|
||
sel_row = -1
|
||
for r, h in enumerate(runs):
|
||
vals = [f"{dots.get(h.status, '•')} {h.name}", tr("co4e.status." + h.status),
|
||
h.progress_text(), h.created_by or "-", h.created_at or "-"]
|
||
for c, val in enumerate(vals):
|
||
it = QTableWidgetItem(str(val))
|
||
if c == 0:
|
||
it.setData(Qt.UserRole, h.id)
|
||
if c == 1:
|
||
it.setForeground(_qcolor(color.get(h.status, p.text)))
|
||
t.setItem(r, c, it)
|
||
if h.id == sel_id:
|
||
sel_row = r
|
||
if sel_row >= 0:
|
||
t.setCurrentCell(sel_row, 0)
|
||
# The sidebar's short run list is the same data — refresh it together.
|
||
self._refresh_side_runs()
|
||
# Active-run count, on the sidebar heading now that the tab strip is gone.
|
||
n = self.manager.active_count()
|
||
label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
|
||
if hasattr(self, "flow_bar"):
|
||
self.flow_bar.setTabText(0, label)
|
||
head = (self._sections.get("co4e.runs_tab") or (None,))[0]
|
||
if head is not None:
|
||
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
|
||
|
||
def _stop_selected_run(self) -> None:
|
||
row = self.runs_table.currentRow()
|
||
it = self.runs_table.item(row, 0) if row >= 0 else None
|
||
if it is None:
|
||
self.manager.stop_all()
|
||
return
|
||
self.manager.stop(it.data(Qt.UserRole))
|
||
|
||
def _delete_selected_run(self) -> None:
|
||
"""Delete the selected run from the Flow Status history (a running one is
|
||
stopped first). Removes just that single entry."""
|
||
row = self.runs_table.currentRow()
|
||
it = self.runs_table.item(row, 0) if row >= 0 else None
|
||
if it is None:
|
||
self.status_message.emit(tr("co4e.select_run"))
|
||
return
|
||
run_id = it.data(Qt.UserRole)
|
||
h = self.manager.get(run_id) # stop tracking it per-flow if we were
|
||
if h is not None and self._flow_runs.get(h.wf_id) == run_id:
|
||
self._flow_runs.pop(h.wf_id, None)
|
||
self._run_logs.pop(run_id, None)
|
||
self.manager.remove(run_id) # emits `changed` → _refresh_runs
|
||
|
||
def _runs_context_menu(self, pos) -> None:
|
||
from PySide6.QtWidgets import QMenu
|
||
item = self.runs_table.itemAt(pos)
|
||
if item is None:
|
||
return
|
||
self.runs_table.selectRow(item.row())
|
||
menu = QMenu(self)
|
||
menu.addAction(tr("co4e.open_run"),
|
||
lambda: self._open_run_from_table(self.runs_table.item(item.row(), 0)))
|
||
it0 = self.runs_table.item(item.row(), 0)
|
||
rid = it0.data(Qt.UserRole) if it0 is not None else None
|
||
menu.addAction(tr("co4e.open_output"), lambda: self._open_run_output_folder(rid))
|
||
menu.addAction(tr("co4e.rename_run"), self._rename_selected_run)
|
||
menu.addAction(tr("co4e.delete_run"), self._delete_selected_run)
|
||
menu.exec(self.runs_table.viewport().mapToGlobal(pos))
|
||
|
||
# ---- workspace binding + output folder (where flow files land) --------
|
||
def set_project(self, project_id: str) -> None:
|
||
"""Bind Co4E to the SELECTED Workspace project so flow output (and per-run
|
||
folders) land in THAT project's workspace — mirroring how Cowork writes to
|
||
the project folder — instead of the global/config output dir."""
|
||
from ..core.projects import load_project
|
||
self._project_id = project_id or ""
|
||
self._project_dir = None
|
||
if project_id and project_id not in ("", "default"):
|
||
proj = load_project(project_id)
|
||
if proj is not None:
|
||
self._project_dir = Path(proj.workspace_dir())
|
||
# Route the run manager's output at the selected workspace, and filter
|
||
# Flow Status to this workspace's runs.
|
||
self.manager.set_output_root(self._flow_output_root())
|
||
self.manager.set_current_project(self._project_id)
|
||
if hasattr(self, "ws_folder_btn"):
|
||
self._refresh_ws_folder_btn()
|
||
|
||
def _flow_output_root(self) -> Path:
|
||
"""The workspace folder flow outputs are written under (one subfolder per
|
||
flow). Uses the SELECTED project's workspace when one is bound, else the
|
||
global Cowork output dir. Mirrors co4e_run_manager._out_dir's base."""
|
||
if self._project_dir is not None:
|
||
return self._project_dir / "co4e"
|
||
try:
|
||
base = self.ctx.config.cowork_output_dir()
|
||
except Exception: # noqa: BLE001
|
||
base = co4e.CO4E_DIR / "runs"
|
||
return Path(base) / "co4e"
|
||
|
||
def _refresh_ws_folder_btn(self) -> None:
|
||
root = self._flow_output_root()
|
||
parts = root.parts
|
||
short = "…/" + "/".join(parts[-2:]) if len(parts) > 2 else str(root)
|
||
self.ws_folder_btn.setText(short)
|
||
self.ws_folder_btn.setToolTip(tr("co4e.tt_open_workspace", path=str(root)))
|
||
|
||
def _open_workspace_folder(self) -> None:
|
||
from .osutil import open_location
|
||
root = self._flow_output_root()
|
||
try:
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
except OSError:
|
||
pass
|
||
open_location(str(root))
|
||
|
||
def _open_run_output_folder(self, run_id) -> None:
|
||
"""Open the workspace folder a specific run wrote its files into."""
|
||
from .osutil import open_location
|
||
h = self.manager.get(run_id) if run_id else None
|
||
path = Path(h.out_dir) if (h is not None and h.out_dir) else self._flow_output_root()
|
||
if not path.exists():
|
||
path = self._flow_output_root()
|
||
try:
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
except OSError:
|
||
pass
|
||
open_location(str(path))
|
||
|
||
def _rename_selected_run(self) -> None:
|
||
"""Rename the selected run in Flow Status — updates the run entry AND its
|
||
underlying saved flow / open tab so the name stays consistent everywhere."""
|
||
row = self.runs_table.currentRow()
|
||
it = self.runs_table.item(row, 0) if row >= 0 else None
|
||
if it is None:
|
||
self.status_message.emit(tr("co4e.select_run"))
|
||
return
|
||
run_id = it.data(Qt.UserRole)
|
||
h = self.manager.get(run_id)
|
||
if h is None:
|
||
return
|
||
from PySide6.QtWidgets import QInputDialog
|
||
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
|
||
tr("co4e.rename_run_label"), text=h.name)
|
||
new = (new or "").strip()
|
||
if not ok or not new or new == h.name:
|
||
return
|
||
self.manager.rename(run_id, new) # run entry + snapshot (→ refresh)
|
||
# Keep the underlying saved flow + any open tab in sync.
|
||
wf = co4e.get_workflow(h.wf_id)
|
||
if wf is not None:
|
||
wf.name = new
|
||
co4e.save_workflow(wf)
|
||
self._reload_sidebar()
|
||
for i, f in enumerate(self._flows):
|
||
if f.id == h.wf_id:
|
||
f.name = new
|
||
self.flow_bar.setTabText(i + 1, new)
|
||
break
|
||
if self._wf.id == h.wf_id and self.name_edit.text() != new:
|
||
self.name_edit.setText(new) # updates _wf.name + active tab text
|
||
|
||
def _run_selected_in_background(self) -> None:
|
||
wf = self._selected_wf()
|
||
if wf is None:
|
||
self.status_message.emit(tr("co4e.select_flow"))
|
||
return
|
||
self.manager.start(wf, skill_map=self._skill_map(),
|
||
plan_mode=(self._current_mode() == "plan"))
|
||
# Used to jump the sidebar back to the Workflows tab; with one column
|
||
# there is nothing to jump to — show the run that just started instead.
|
||
self._refresh_side_runs()
|
||
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
|
||
|
||
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
|
||
"""Resolve a flow id to a Workflow — saved, or the open canvas."""
|
||
wf = co4e.get_workflow(wf_id)
|
||
if wf is not None:
|
||
return wf
|
||
if self._wf.id == wf_id:
|
||
self._sync_wf_from_canvas()
|
||
return self._wf
|
||
return None
|
||
|
||
def _rerun_run_item(self, item) -> None:
|
||
"""Double-click a run in the history → run that flow again (in background)."""
|
||
h = self.manager.get(item.data(Qt.UserRole))
|
||
if h is None:
|
||
return
|
||
wf = self._wf_by_id(h.wf_id)
|
||
if wf is None:
|
||
self.status_message.emit(tr("co4e.flow_gone"))
|
||
return
|
||
self.manager.start(wf, skill_map=self._skill_map(),
|
||
plan_mode=(self._current_mode() == "plan"))
|
||
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
|
||
|
||
def _open_run_from_table(self, item) -> None:
|
||
"""Double-click a run row in the Runs tab → open that flow's tab and show
|
||
its live status (opens/focuses the tab; _open_flow reflects the run)."""
|
||
id_item = self.runs_table.item(item.row(), 0)
|
||
if id_item is None:
|
||
return
|
||
h = self.manager.get(id_item.data(Qt.UserRole))
|
||
if h is None:
|
||
return
|
||
# Prefer the flow the run kept a reference to (works even after its tab was
|
||
# closed or if it was never saved); fall back to resolving by id.
|
||
wf = getattr(h, "wf", None) or self._wf_by_id(h.wf_id)
|
||
if wf is None:
|
||
self.status_message.emit(tr("co4e.flow_gone"))
|
||
return
|
||
self._open_flow(wf)
|
||
# reflect this run's step statuses (done/error/running) on the canvas
|
||
for nid, st in h.node_status.items():
|
||
self.canvas.update_node_status(nid, st)
|
||
self.status_message.emit(tr("co4e.viewing_flow", name=wf.name))
|
||
|
||
def showEvent(self, e): # noqa: N802
|
||
# Guarantee the status list is current whenever the tab is shown again.
|
||
self._refresh_runs()
|
||
super().showEvent(e)
|
||
|
||
def _out_dir(self) -> Path:
|
||
# Save chat/flow deliverables into the SELECTED workspace (the active
|
||
# project's folder via _flow_output_root), so files land where the user
|
||
# works with them — not in the config/install folder.
|
||
d = self._flow_output_root() / co4e.slugify(self._wf.name or "flow")
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
# ---- chat (with /agent /skill directives) -----------------------------
|
||
def _chat_send(self) -> None:
|
||
text = self.chat_input.text().strip()
|
||
if not text or self._chat_worker is not None:
|
||
return
|
||
self.chat_input.clear()
|
||
self._append_chat("user", text)
|
||
skill_prefix, request, info = skills_mod.parse_skill_command(text)
|
||
if info is not None:
|
||
self._append_chat("system", info)
|
||
return
|
||
system_parts = []
|
||
if skill_prefix:
|
||
system_parts.append(skill_prefix)
|
||
agent_name, request = self._extract_agent_directive(request)
|
||
model = ""
|
||
if agent_name:
|
||
persona = self._resolve_agent(agent_name)
|
||
if persona is None:
|
||
self._append_chat("system", tr("co4e.agent_not_found", name=agent_name))
|
||
return
|
||
system_parts.append(persona[0])
|
||
model = persona[1]
|
||
# Auto Model Routing — only when the user hasn't pinned an agent's own
|
||
# model (an explicit pin wins). May switch provider+model for this turn.
|
||
if not model:
|
||
model = self._apply_co4e_routing(request)
|
||
self._run_chat_turn(system_parts, request, model)
|
||
|
||
def _apply_co4e_routing(self, request: str) -> str:
|
||
"""Route this Co4E turn to the best-fit model. Returns the model id to
|
||
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
|
||
a cross-provider switch is chosen. Off → no-op. Manual → confirm first.
|
||
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()
|
||
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
|
||
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
|
||
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
|
||
self._co4e_routed_provider = None
|
||
return ""
|
||
|
||
def _extract_agent_directive(self, text: str):
|
||
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
|
||
if not m:
|
||
return "", text
|
||
name = m.group(1)
|
||
rest = (text[:m.start()] + " " + text[m.end():]).strip()
|
||
return name, rest
|
||
|
||
def _resolve_agent(self, name: str):
|
||
low = name.lower()
|
||
for a in BUILTIN_AGENTS:
|
||
if a.slug == low or a.name.lower() == low:
|
||
return (f"You are the {a.role} agent — {a.name}.\n{a.instructions}", "")
|
||
for ca in co4e.list_custom_agents():
|
||
if co4e.slugify(ca.name) == low or ca.name.lower() == low:
|
||
return (f"You are the {ca.role} agent — {ca.name}.\n{ca.instructions}", ca.model)
|
||
return None
|
||
|
||
def _run_chat_turn(self, system_parts: List[str], request: str, model: str) -> None:
|
||
self.chat_send_btn.setEnabled(False)
|
||
log = self.chat_log # THIS flow's conversation (captured)
|
||
log._co4e_plan_bubble = None # a fresh plan for this turn
|
||
ctx = self.ctx
|
||
out_dir = self._out_dir()
|
||
sys_text = "\n\n".join(p for p in system_parts if p)
|
||
prompt = f"{sys_text}\n\n{request}" if sys_text else request
|
||
assistant = log.add_assistant() # stream into this live bubble
|
||
state = {"text": ""}
|
||
wf = getattr(self, "_wf", None)
|
||
wf_id = wf.id if wf is not None else None
|
||
flow_label = wf.name if wf is not None else "flow"
|
||
|
||
def job(worker: AgentWorker):
|
||
from ..core import agent_roles, usage_tracker as ut
|
||
from ..core.chat_agent import run_cowork
|
||
from ..core.co4e_runner import _usage_delta
|
||
# An Auto/Manual routing switch may target a different provider.
|
||
provider = ctx.build_provider_for(getattr(self, "_co4e_routed_provider", None), model or None)
|
||
messages = [{"role": "user", "content": prompt}]
|
||
ut.set_context("co4e", flow_label) # attribute + measure this turn's usage
|
||
ut.begin_accumulation()
|
||
base = ut.accumulated()
|
||
|
||
def _emit(ev):
|
||
if not isinstance(ev, dict):
|
||
return
|
||
t = ev.get("type")
|
||
if t == "text":
|
||
worker.emit_event({"type": "text", "delta": ev.get("delta", "")})
|
||
elif t == "plan_set":
|
||
worker.emit_event({"type": "plan_set", "steps": ev.get("steps") or []})
|
||
try:
|
||
run_cowork(provider, messages, out_dir, _emit, worker.is_cancelled,
|
||
security_config=ctx.config, agent_role=agent_roles.COWORK,
|
||
run_to_completion=True, enforce_rules=False)
|
||
usage = _usage_delta(base, ctx.config)
|
||
finally:
|
||
ut.end_accumulation()
|
||
for m in reversed(messages):
|
||
if m.get("role") == "assistant" and m.get("content"):
|
||
return {"text": str(m["content"]), "usage": usage}
|
||
return {"text": "", "usage": usage}
|
||
|
||
def on_event(ev):
|
||
if ev.get("type") == "text":
|
||
state["text"] += ev.get("delta", "")
|
||
assistant.set_markdown(state["text"])
|
||
log.scroll_to_bottom()
|
||
elif ev.get("type") == "plan_set":
|
||
self._append_plan(ev.get("steps") or [], log=log)
|
||
|
||
def done(result: dict):
|
||
self._chat_worker = None
|
||
self.chat_send_btn.setEnabled(True)
|
||
final = result.get("text") or state["text"]
|
||
assistant.set_markdown(final or "(no output)")
|
||
self._apply_usage(assistant, wf_id, result.get("usage"))
|
||
log.scroll_to_bottom()
|
||
|
||
def failed(err: str):
|
||
self._chat_worker = None
|
||
self.chat_send_btn.setEnabled(True)
|
||
self._append_chat("error", f"[error: {err}]", log=log)
|
||
|
||
w = AgentWorker(job)
|
||
w.event.connect(on_event)
|
||
w.finished_ok.connect(done)
|
||
w.failed.connect(failed)
|
||
self._chat_worker = w
|
||
w.start()
|
||
|
||
def _append_chat(self, role: str, text: str, log: "ChatView" = None) -> None:
|
||
"""Add one message bubble to a flow's conversation. ``log`` defaults to the
|
||
active flow's log; a run/stream passes its OWN captured log so events land
|
||
in the right flow even if the user switches tabs mid-run."""
|
||
log = log or self.chat_log
|
||
if role == "user":
|
||
bub = log.add_user(text)
|
||
elif role == "assistant":
|
||
bub = log.add_assistant()
|
||
bub.set_markdown(text)
|
||
elif role == "error":
|
||
bub = log.add_error(text)
|
||
else: # system status marker
|
||
bub = log.add_status(text)
|
||
log.scroll_to_bottom()
|
||
return bub
|
||
|
||
# ---- token / cost accounting (shown per-message + as a flow total) ------
|
||
def _fmt_usage(self, d_in: int, d_out: int, d_cache: int, cost_usd: float) -> str:
|
||
"""The Cowork-style footer string: ↓in ↑out ▤(in+out+cache) $cost, priced
|
||
with the Monitoring model-price table in the app's display currency."""
|
||
from ..core import model_pricing as mp, usage_tracker as ut
|
||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||
return (f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
|
||
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
|
||
f"{ut.format_cost(cost_usd, pricing)}")
|
||
|
||
def _apply_usage(self, bub, wf_id, usage) -> None:
|
||
"""Attach a token/cost footer to a step's bubble and add it to the flow's
|
||
running total (mirrors Cowork's per-message + conversation-total display)."""
|
||
if not isinstance(usage, dict):
|
||
return
|
||
d_in = int(usage.get("in", 0) or 0)
|
||
d_out = int(usage.get("out", 0) or 0)
|
||
d_cache = int(usage.get("cache", 0) or 0)
|
||
cost = float(usage.get("cost_usd", 0.0) or 0.0)
|
||
if bub is not None and (d_in or d_out):
|
||
try:
|
||
bub.add_usage(self._fmt_usage(d_in, d_out, d_cache, cost))
|
||
except Exception: # noqa: BLE001 - a usage footer must never break the run
|
||
pass
|
||
if wf_id is not None:
|
||
tot = self._flow_usage.setdefault(wf_id, {"in": 0, "out": 0, "cache": 0, "cost": 0.0})
|
||
tot["in"] += d_in; tot["out"] += d_out; tot["cache"] += d_cache; tot["cost"] += cost
|
||
self._refresh_usage_total(wf_id)
|
||
|
||
def _refresh_usage_total(self, only_wf: str = None) -> None:
|
||
"""Update the bottom conversation total to the CURRENT flow's running
|
||
usage (skip if the event is for a different, background flow)."""
|
||
lbl = getattr(self, "_usage_total_lbl", None)
|
||
if lbl is None:
|
||
return
|
||
wf = getattr(self, "_wf", None)
|
||
wf_id = wf.id if wf is not None else None
|
||
if only_wf is not None and only_wf != wf_id:
|
||
return
|
||
tot = self._flow_usage.get(wf_id) if wf_id else None
|
||
if not tot or not (tot["in"] or tot["out"]):
|
||
lbl.setText("")
|
||
return
|
||
lbl.setText(self._fmt_usage(int(tot["in"]), int(tot["out"]),
|
||
int(tot["cache"]), float(tot["cost"])))
|
||
|
||
def _append_diff(self, title: str, diff: str, log: "ChatView" = None) -> None:
|
||
"""Render a before/after diff as a collapsible colored diff bubble."""
|
||
log = log or self.chat_log
|
||
log.add_diff(f"▤ {title}", diff)
|
||
log.scroll_to_bottom()
|
||
|
||
def _append_plan(self, steps, log: "ChatView" = None) -> None:
|
||
"""Show the plan INLINE in the conversation as an expandable block; update
|
||
the same (per-flow) bubble in place so steps tick off (✓) as they complete."""
|
||
log = log or self.chat_log
|
||
body = _fmt_plan(steps)
|
||
if not body:
|
||
return
|
||
if getattr(log, "_co4e_plan_bubble", None) is None:
|
||
log._co4e_plan_bubble = log.add_plan(body)
|
||
else:
|
||
log._co4e_plan_bubble.set_plain(body)
|
||
log.scroll_to_bottom()
|
||
|
||
# ---- i18n -------------------------------------------------------------
|
||
def _retranslate(self) -> None:
|
||
for key in self._sections:
|
||
self._sync_section_arrow(key)
|
||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||
self.wf_new_btn.setText(tr("co4e.new"))
|
||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||
self.runs_btn.setText(tr("co4e.runs_tab"))
|
||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||
self.runs_back_btn.setText(tr("co4e.back_to_flow"))
|
||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||
self.runs_title.setText(tr("co4e.running_flows"))
|
||
self.run_stop_btn.setText(tr("co4e.stop"))
|
||
self.run_rename_btn.setText(tr("co4e.rename_run"))
|
||
self.run_del_btn.setText(tr("co4e.delete_run"))
|
||
self.run_clear_btn.setText(tr("co4e.clear_done"))
|
||
self._refresh_ws_folder_btn()
|
||
self.runs_table.setHorizontalHeaderLabels([
|
||
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
|
||
tr("co4e.runs_col_by"), tr("co4e.runs_col_at")])
|
||
self._reload_sidebar()
|
||
self._refresh_runs()
|
||
|
||
|
||
def _html_escape(text: str) -> str:
|
||
return (text or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||
|
||
|
||
def _qcolor(hex_str: str):
|
||
from PySide6.QtGui import QColor
|
||
return QColor(hex_str)
|