Files
cowork-local/ui/routing_toggle.py
T

244 lines
9.5 KiB
Python

"""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
``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
from typing import Any, Callable, Optional
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QMessageBox,
QWidget,
)
from ..i18n import on_language_changed, tr
class RoutingToggle(QWidget):
"""A small ``Routing: [Off ▾]`` control bound to one chat surface.
Storage is injectable so the same widget can back a per-workspace mode, a
global mode, or anything else:
* ``get_mode()`` returns the current mode string to display.
* ``set_mode(mode)`` persists a newly-chosen mode.
When omitted, both default to the ACTIVE workspace's per-surface mode
(``AppContext.project_routing_mode`` / ``set_project_routing_mode``).
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
"""
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
def __init__(
self,
ctx: Any,
surface: str,
parent: Optional[QWidget] = None,
*,
get_mode: Optional[Callable[[], str]] = None,
set_mode: Optional[Callable[[str], None]] = None,
) -> None:
"""Công tắc chế độ định tuyến cho một bề mặt chat.
``get_mode``/``set_mode`` tiêm được để dùng lại công tắc này ở chỗ đọc/ghi
chế độ theo cách khác, mà không phải chép lại cả widget.
"""
super().__init__(parent)
self.ctx = ctx
self.surface = surface
# Default to per-workspace storage (each workspace keeps its own mode).
self._get_mode = get_mode or (lambda: ctx.project_routing_mode(surface))
self._set_mode = set_mode or (lambda m: ctx.set_project_routing_mode(surface, m))
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(4)
self._label = QLabel(tr("routing.toggle_label"))
self._label.setObjectName("hint")
self._combo = QComboBox()
self._combo.setToolTip(tr("routing.toggle_tooltip"))
# Mode names differ in length per language ("Thủ công" is wider than
# "手動"), and a combo only re-measures itself under this policy — without
# it the width stays frozen at the language the widget was built in and
# the longer translation is cut off.
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
# (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)
self.refresh() # reflect the current (per-workspace) mode
self._combo.currentIndexChanged.connect(self._on_changed)
lay.addWidget(self._label)
lay.addWidget(self._combo)
# Registered HERE, not by the four surfaces that embed this widget
# (Cowork, Co4E, AI-Edit): every one of them had forgotten to, so the
# control stayed frozen in the language it was built in. Owning it here
# means no future embedder can forget either.
on_language_changed(self.retranslate)
def current_mode(self) -> str:
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
return self._combo.currentData() or "off"
def refresh(self) -> None:
"""Re-read the backing mode (e.g. after switching workspace) and show it
without emitting a spurious change."""
try:
mode = self._get_mode() or "off"
except Exception: # noqa: BLE001
mode = "off"
idx = self._combo.findData(mode)
if idx < 0:
idx = 0
self._combo.blockSignals(True)
self._combo.setCurrentIndex(idx)
self._combo.blockSignals(False)
def retranslate(self) -> None:
"""Re-apply labels after a language change."""
self._label.setText(tr("routing.toggle_label"))
self._combo.setToolTip(tr("routing.toggle_tooltip"))
for i, (value, key) in enumerate(self._modes):
self._combo.setItemText(i, tr(key))
def _on_changed(self, _idx: int) -> None:
"""Lưu chế độ vừa chọn. Nuốt lỗi có chủ ý: một cú đổi công tắc không được
phép làm vỡ giao diện.
"""
mode = self.current_mode()
try:
self._set_mode(mode)
except Exception: # noqa: BLE001 — never let a toggle change crash the UI
pass
self.mode_changed.emit(mode)
class AutoRunToggle(QWidget):
"""A checkbox that auto-approves commands for the ACTIVE workspace.
Checked → commands run without a confirm dialog (auto-approve) in this
workspace; unchecked → the Approve/Reject dialog is shown. Backed by the
per-workspace ``auto_run`` override (``AppContext.set_project_auto_run``),
falling back to the global ``cowork_confirm_commands`` when unset.
"""
toggled_auto = Signal(bool)
def __init__(self, ctx: Any, parent: Optional[QWidget] = None) -> None:
"""Ô tick tự chạy: đổi model xong có tự tiếp tục lượt hay dừng lại hỏi."""
super().__init__(parent)
self.ctx = ctx
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(4)
self._chk = QCheckBox(tr("routing.autorun_label"))
self._chk.setToolTip(tr("routing.autorun_tooltip"))
self.refresh()
self._chk.toggled.connect(self._on_toggled)
lay.addWidget(self._chk)
on_language_changed(self.retranslate) # same reason as RoutingToggle
def refresh(self) -> None:
"""Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""
try:
auto = bool(self.ctx.project_auto_run())
except Exception: # noqa: BLE001
auto = False
self._chk.blockSignals(True)
self._chk.setChecked(auto)
self._chk.blockSignals(False)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._chk.setText(tr("routing.autorun_label"))
self._chk.setToolTip(tr("routing.autorun_tooltip"))
def _on_toggled(self, checked: bool) -> None:
"""Ghi trạng thái tự chạy vào project đang mở."""
try:
self.ctx.set_project_auto_run(bool(checked))
except Exception: # noqa: BLE001
pass
self.toggled_auto.emit(bool(checked))
def confirm_switch(parent: QWidget, decision: Any, timeout_sec: float) -> bool:
"""Modal Manual-mode confirm: ask before switching, auto-keep on timeout.
Returns True if the user approved the switch; False if they declined or the
``timeout_sec`` window elapsed (→ keep the current model, per spec). The
"Keep current" button shows a live countdown so the timeout is visible.
"""
from ..core.routing.models import split_key
from_id = split_key(decision.from_model)[1] if decision.from_model else "—"
to_id = split_key(decision.to_model)[1] if decision.to_model else "—"
box = QMessageBox(parent)
box.setIcon(QMessageBox.Question)
box.setWindowTitle(tr("routing.confirm_title"))
box.setText(tr(
"routing.confirm_body",
task=decision.task_type or "?",
from_model=from_id,
to_model=to_id,
gain=f"{decision.score_gain:.2f}",
reason=decision.reason,
))
yes_btn = box.addButton(tr("routing.confirm_yes"), QMessageBox.AcceptRole)
no_btn = box.addButton(tr("routing.confirm_no"), QMessageBox.RejectRole)
box.setDefaultButton(no_btn)
# Countdown that auto-declines (keep current) when the window elapses.
remaining = {"secs": int(max(1, round(timeout_sec)))}
timer = QTimer(box)
timer.setInterval(1000)
def _tick() -> None:
"""Đếm lùi mỗi giây; hết giờ thì tự đóng hộp thoại theo hướng GIỮ model hiện tại.
Hết giờ mà tự đổi model là quyết định thay người dùng — mặc định an toàn
phải là không đổi gì.
"""
remaining["secs"] -= 1
if remaining["secs"] <= 0:
timer.stop()
box.done(QMessageBox.RejectRole) # timeout → keep current
else:
no_btn.setText(tr("routing.confirm_countdown", secs=remaining["secs"]))
no_btn.setText(tr("routing.confirm_countdown", secs=remaining["secs"]))
timer.timeout.connect(_tick)
if timeout_sec > 0:
timer.start()
box.exec()
timer.stop()
return box.clickedButton() is yes_btn
__all__ = ["RoutingToggle", "AutoRunToggle", "confirm_switch"]