#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Batch history tab - list / cancel / usage / redownload / resume.""" from __future__ import annotations import os from pathlib import Path from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTableWidget, QTableWidgetItem, QHeaderView, QTextEdit, QMessageBox, QAbstractItemView, QSplitter, ) from dotenv import load_dotenv class _BatchOpsWorker(QThread): """Run a blocking batch-history callable off the UI thread.""" done = pyqtSignal(bool, str, object) # ok, message, payload log = pyqtSignal(str) def __init__(self, task, project_root: Path): super().__init__() self._task = task self._project_root = project_root def run(self): old = os.getcwd() try: os.chdir(str(self._project_root)) load_dotenv() ok, msg, payload = self._task(self.log.emit) self.done.emit(bool(ok), str(msg), payload) except Exception as exc: import traceback self.log.emit(traceback.format_exc()) self.done.emit(False, f"Error: {exc}", None) finally: try: os.chdir(old) except Exception: pass class BatchTab(QWidget): """Manage past / in-flight Anthropic Message Batches for this project.""" COLUMNS = [ "Batch ID", "Status", "Created", "Requests", "Model", "Estimate", "Actual $", "Files", ] def __init__(self, parent=None): super().__init__(parent) self.parent_window = parent self.project_root = Path( getattr(parent, "project_root", None) or Path(__file__).resolve().parent.parent ) self._worker: _BatchOpsWorker | None = None self._rows: list[dict] = [] self._loaded_once = False self._init_ui() def showEvent(self, event): super().showEvent(event) # Refresh every time the Batches page is shown so live status stays current. if self._worker is not None and self._worker.isRunning(): return self._loaded_once = True QTimer.singleShot(0, lambda: self.refresh_list(live=True)) def _init_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(10) title = QLabel("Batch History") title.setStyleSheet("color:#ffffff;font-size:18px;font-weight:bold;") layout.addWidget(title) hint = QLabel( "Local Anthropic Message Batch runs for this project. " "Cancel / redownload / usage never submit a new batch." ) hint.setWordWrap(True) hint.setStyleSheet("color:#9d9d9d;font-size:12px;") layout.addWidget(hint) toolbar = QHBoxLayout() toolbar.setSpacing(8) self.refresh_btn = QPushButton("Refresh") self.refresh_btn.setToolTip("Reload local history and refresh live status for active batches") self.refresh_btn.clicked.connect(lambda: self.refresh_list(live=True)) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.setToolTip("Cancel selected in-progress batch(es)") self.cancel_btn.clicked.connect(self.cancel_selected) self.usage_btn = QPushButton("Usage") self.usage_btn.setToolTip("Sum real billed tokens for the selected ended batch") self.usage_btn.clicked.connect(self.usage_selected) self.redownload_btn = QPushButton("Redownload") self.redownload_btn.setToolTip("Re-fetch results into log/batch_results.json (no re-submit)") self.redownload_btn.clicked.connect(self.redownload_selected) self.resume_btn = QPushButton("Resume") self.resume_btn.setToolTip("Activate this batch and continue poll/consume on the Translation tab") self.resume_btn.clicked.connect(self.resume_selected) for btn in ( self.refresh_btn, self.cancel_btn, self.usage_btn, self.redownload_btn, self.resume_btn, ): btn.setStyleSheet( "QPushButton{background-color:#3c3c3c;color:#cccccc;border:1px solid #555555;" "border-radius:4px;padding:6px 12px;}" "QPushButton:hover{border-color:#007acc;}" "QPushButton:disabled{color:#666666;border-color:#444444;}" ) toolbar.addWidget(btn) toolbar.addStretch() layout.addLayout(toolbar) splitter = QSplitter(Qt.Vertical) self.table = QTableWidget(0, len(self.COLUMNS)) self.table.setHorizontalHeaderLabels(self.COLUMNS) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setAlternatingRowColors(True) self.table.verticalHeader().setVisible(False) self.table.setStyleSheet( "QTableWidget{background-color:#1e1e1e;color:#cccccc;gridline-color:#3a3a3a;" "alternate-background-color:#252526;}" "QHeaderView::section{background-color:#2d2d30;color:#cccccc;padding:4px;" "border:1px solid #3a3a3a;}" ) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) for i in range(1, len(self.COLUMNS)): header.setSectionResizeMode(i, QHeaderView.ResizeToContents) self.table.itemSelectionChanged.connect(self._update_button_states) splitter.addWidget(self.table) self.log = QTextEdit() self.log.setReadOnly(True) self.log.setPlaceholderText("Operation log…") self.log.setStyleSheet( "QTextEdit{background-color:#1e1e1e;color:#cccccc;border:1px solid #3a3a3a;" "font-family:monospace;font-size:12px;}" ) splitter.addWidget(self.log) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) layout.addWidget(splitter, 1) self._update_button_states() def _append_log(self, text: str): if not text: return self.log.append(text.rstrip()) def _selected_entries(self) -> list[dict]: rows = sorted({idx.row() for idx in self.table.selectedIndexes()}) out = [] for r in rows: if 0 <= r < len(self._rows): out.append(self._rows[r]) return out def _update_button_states(self): busy = self._worker is not None and self._worker.isRunning() entries = self._selected_entries() n = len(entries) self.refresh_btn.setEnabled(not busy) self.cancel_btn.setEnabled( not busy and n >= 1 and any( (e.get("api_status") == "in_progress") or (e.get("status") == "submitted") for e in entries ) ) self.usage_btn.setEnabled(not busy and n == 1) self.redownload_btn.setEnabled( not busy and n == 1 and entries[0].get("status") in ("ended", "fetched", "consumed", "submitted", "canceling") ) self.resume_btn.setEnabled( not busy and n == 1 and entries[0].get("status") in ("submitted", "canceling", "ended", "fetched") ) def _set_busy(self, busy: bool): if busy: for btn in ( self.refresh_btn, self.cancel_btn, self.usage_btn, self.redownload_btn, self.resume_btn, ): btn.setEnabled(False) else: self._update_button_states() def _clear_worker(self): if self.sender() is self._worker: self._worker = None def _run_task(self, task, on_done=None): if self._worker is not None and self._worker.isRunning(): QMessageBox.information(self, "Busy", "A batch operation is already running.") return self._set_busy(True) worker = _BatchOpsWorker(task, self.project_root) self._worker = worker def _finished(ok, msg, payload): self._set_busy(False) if msg: self._append_log(msg) if on_done: try: on_done(ok, msg, payload) except Exception as exc: self._append_log(f"[BATCH] UI update failed: {exc}") worker.log.connect(self._append_log) worker.done.connect(_finished) # Release the reference only after run() returns. Clearing in the done # slot aborts with "QThread: Destroyed while thread is still running". worker.finished.connect(worker.deleteLater) worker.finished.connect(self._clear_worker) worker.start() def _populate_table(self, entries: list[dict]): self._rows = list(entries or []) self.table.setRowCount(len(self._rows)) for r, entry in enumerate(self._rows): est = entry.get("cost_estimate") or {} est_s = "" if isinstance(est, dict) and est.get("batch_cached_cost") is not None: try: est_s = f"${float(est['batch_cached_cost']):.2f}" except (TypeError, ValueError): est_s = "" actual = entry.get("actual_cost") actual_s = f"${actual:.4f}" if isinstance(actual, (int, float)) else "" files = entry.get("file_set") or [] if not isinstance(files, list): files = [str(files)] files_s = ", ".join(str(f) for f in files[:3]) if len(files) > 3: files_s += f" (+{len(files) - 3})" values = [ str(entry.get("id") or ""), str(entry.get("status") or ""), str(entry.get("created_at") or "")[:19], str(entry.get("request_count") or ""), str(entry.get("model") or ""), est_s, actual_s, files_s, ] for c, val in enumerate(values): item = QTableWidgetItem(val) if c == 0: item.setData(Qt.UserRole, entry.get("id")) self.table.setItem(r, c, item) self._update_button_states() def refresh_list(self, live: bool = True): def task(log): from util.batch_history import list_local_batches log("[BATCH] Loading local history...") entries = list_local_batches(refresh_live=live) log(f"[BATCH] {len(entries)} batch(es) in history.") return True, f"Loaded {len(entries)} batch(es).", entries def done(ok, _msg, payload): if ok and isinstance(payload, list): self._populate_table(payload) self._run_task(task, on_done=done) def cancel_selected(self): entries = self._selected_entries() if not entries: return ids = [e["id"] for e in entries if e.get("id")] reply = QMessageBox.question( self, "Cancel Batch?", "Cancel the selected in-progress batch(es)?\n\n" "Anthropic may still finish and bill requests that were already " "in flight when cancel is received.\n" "This does not submit a new batch.", QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return def task(log): from util.batch_history import cancel_batches log(f"[BATCH] Canceling {len(ids)} batch(es)…") results = cancel_batches(ids) lines = [] for r in results: if r.get("ok"): lines.append(f" canceled {r['id']} -> {r.get('api_status')}") else: lines.append(f" failed {r['id']}: {r.get('error')}") for line in lines: log(line) return True, "Cancel finished.", results def done(ok, _msg, _payload): self.refresh_list(live=False) self._run_task(task, on_done=done) def usage_selected(self): entries = self._selected_entries() if len(entries) != 1: return bid = entries[0]["id"] def task(log): from util.batch_history import usage_for_batch log(f"[BATCH] Computing usage for {bid}…") info = usage_for_batch(bid) u = info.get("usage") or {} log( f"[BATCH] tokens: in={u.get('input_tokens', 0)} out={u.get('output_tokens', 0)} " f"cache_read={u.get('cache_read_input_tokens', 0)} " f"cache_write={u.get('cache_creation_input_tokens', 0)} " f"thinking={u.get('thinking_tokens', 0)}" ) cost = info.get("actual_cost") if cost is not None: log(f"[BATCH] estimated billed cost (batch 50% off): ${cost:.4f}") return True, "Usage updated.", info def done(ok, _msg, _payload): self.refresh_list(live=False) self._run_task(task, on_done=done) def redownload_selected(self): entries = self._selected_entries() if len(entries) != 1: return bid = entries[0]["id"] reply = QMessageBox.question( self, "Redownload Results?", f"Re-fetch results for {bid} into log/batch_results.json?\n\n" "This does not create a new batch or re-collect.", QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return def task(log): from util.batch_history import redownload_batch log(f"[BATCH] Redownloading {bid}…") info = redownload_batch(bid) log( f"[BATCH] redownload ok={info.get('succeeded')} err={info.get('errored')} " f"cost≈{info.get('actual_cost')}" ) return True, "Redownload finished.", info def done(ok, msg, payload): self.refresh_list(live=False) if not ok: return reply = QMessageBox.question( self, "Resume Consume?", "Results are ready locally. Switch to Translation and resume consume now?", QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: self._resume_with_state("fetched", entries[0]) self._run_task(task, on_done=done) def resume_selected(self): entries = self._selected_entries() if len(entries) != 1: return entry = entries[0] bid = entry["id"] def task(log): from util.batch_history import activate_for_resume log(f"[BATCH] Activating {bid} for resume…") state = activate_for_resume(bid) log(f"[BATCH] active resume state: {state}") return True, f"Activated ({state}).", {"state": state, "entry": entry} def done(ok, _msg, payload): self.refresh_list(live=False) if not ok or not payload: return self._resume_with_state(payload["state"], payload.get("entry") or entry) self._run_task(task, on_done=done) def _resume_with_state(self, resume_state: str, entry: dict): parent = self.parent_window if parent is None or not hasattr(parent, "translation_tab"): QMessageBox.warning(self, "Resume", "Translation tab is not available.") return tt = parent.translation_tab file_set = entry.get("file_set") or [] if file_set and hasattr(tt, "select_files_by_name"): tt.select_files_by_name(file_set) # Switch to Translation page. if hasattr(parent, "switch_page"): page = getattr(parent, "PAGE_TRANSLATION", 3) parent.switch_page(page) reply = QMessageBox.question( self, "Start Resume?", f"Start Batch Translate resume ({resume_state}) on the Translation tab?\n\n" "This will not clear batch files or submit a new batch.", QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return tt.start_translation(forced_resume_state=resume_state)