diff --git a/gooey-gui/app/components/bulkProgress/BulkProgressCard.css b/gooey-gui/app/components/bulkProgress/BulkProgressCard.css
new file mode 100644
index 000000000..20350b68a
--- /dev/null
+++ b/gooey-gui/app/components/bulkProgress/BulkProgressCard.css
@@ -0,0 +1,510 @@
+.bulk-progress-card {
+ --bulk-progress-detail-label-width: 7.5rem;
+ background: #fff;
+ border: 1px solid #e4e4e7;
+ border-radius: 8px;
+ color: #070b1a;
+ margin: 0 0 1rem;
+ overflow: hidden;
+}
+
+.bulk-progress-card-running {
+ padding: 1.5rem;
+}
+
+.bulk-progress-running-header {
+ align-items: center;
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+}
+
+.bulk-progress-card-stopped {
+ background: #fff7d8;
+ border-color: #f0dc92;
+}
+
+.bulk-progress-card-error {
+ background: #fff4f2;
+ border-color: #f5b8b0;
+}
+
+.bulk-progress-card-complete {
+ background: #edf7e9;
+ border-color: #bdddb8;
+}
+
+.bulk-progress-main {
+ align-items: center;
+ display: flex;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.bulk-progress-main-left {
+ flex: 0 0 auto;
+}
+
+.bulk-progress-main-right {
+ display: flex;
+ flex: 1 1 auto;
+ justify-content: flex-start;
+ min-width: 0;
+}
+
+.bulk-progress-ring {
+ border-radius: 50%;
+ display: grid;
+ flex: 0 0 auto;
+ height: 92px;
+ place-items: center;
+ position: relative;
+ width: 92px;
+}
+
+.bulk-progress-ring svg {
+ inset: 0;
+ overflow: visible;
+ position: absolute;
+}
+
+.bulk-progress-ring circle {
+ fill: none;
+ stroke-width: 8;
+}
+
+.bulk-progress-ring-track {
+ stroke: #e6e6e6;
+}
+
+.bulk-progress-ring-bar {
+ stroke: var(--bulk-progress-accent);
+ stroke-linecap: round;
+ transition: stroke-dashoffset 2400ms cubic-bezier(0.22, 1, 0.36, 1);
+ transform: rotate(-90deg);
+ transform-origin: 50% 50%;
+}
+
+.bulk-progress-ring > div {
+ align-items: center;
+ background-color: #fff;
+ border-radius: 50%;
+ display: flex;
+ flex-direction: column;
+ height: 70px;
+ justify-content: center;
+ line-height: 1;
+ overflow: hidden;
+ text-align: center;
+ width: 70px;
+}
+
+.bulk-progress-card-stopped .bulk-progress-ring > div {
+ background-color: #fff7d8;
+}
+
+.bulk-progress-card-error .bulk-progress-ring > div {
+ background-color: #fff4f2;
+}
+
+.bulk-progress-ring strong {
+ font-size: 0.98rem;
+ line-height: 1;
+}
+
+.bulk-progress-meta,
+.bulk-progress-stat-caption,
+.bulk-progress-workflow-elapsed {
+ color: #59617d;
+}
+
+.bulk-progress-copy {
+ --bulk-progress-dot-size: 0.65rem;
+ --bulk-progress-dot-gap: 0.55rem;
+ --bulk-progress-text-column-inset: calc(
+ var(--bulk-progress-dot-size) + var(--bulk-progress-dot-gap)
+ );
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ padding-top: 0.125rem;
+ text-align: left;
+ width: 100%;
+}
+
+.bulk-progress-kicker {
+ align-items: center;
+ display: flex;
+ font-size: 0.98rem;
+ gap: var(--bulk-progress-dot-gap, 0.55rem);
+}
+
+.bulk-progress-dot {
+ background: #d9a642;
+ border-radius: 999px;
+ flex-shrink: 0;
+ height: var(--bulk-progress-dot-size, 0.65rem);
+ margin-top: 0.085rem;
+ width: var(--bulk-progress-dot-size, 0.65rem);
+}
+
+.bulk-progress-card-running .bulk-progress-dot {
+ animation: bulk-progress-pulse 0.8s ease-in-out infinite;
+}
+
+.bulk-progress-card-error .bulk-progress-dot {
+ background: #b42318;
+}
+
+.bulk-progress-stop-icon {
+ color: #9d7b1f;
+ flex-shrink: 0;
+ font-size: var(--bulk-progress-dot-size, 0.65rem);
+ line-height: 1;
+ margin-top: 0.085rem;
+}
+
+.bulk-progress-headline {
+ align-self: stretch;
+ font-family: avenir-lt-w01_85-heavy1475544, avenir-lt-w05_85-heavy,
+ "Space Grotesk", sans-serif;
+ font-size: 1.55rem;
+ line-height: 1.15;
+ margin: 0.55rem 0;
+ margin-inline-start: var(--bulk-progress-text-column-inset, 0);
+ text-align: left;
+}
+
+.bulk-progress-meta {
+ align-items: center;
+ align-self: stretch;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.9rem;
+ gap: 0.45rem;
+ justify-content: flex-start;
+ line-height: 1.35;
+ margin-inline-start: var(--bulk-progress-text-column-inset, 0);
+ text-align: left;
+}
+
+.bulk-progress-meta-item {
+ align-items: center;
+ display: inline-flex;
+ gap: 0.25rem;
+ white-space: nowrap;
+}
+
+.bulk-progress-meta-separator {
+ color: #59617d;
+ white-space: nowrap;
+}
+
+.bulk-progress-detail {
+ border-top: 1px solid rgba(0, 0, 0, 0.09);
+ margin-top: 1.5rem;
+ padding: 1.45rem 0 0;
+}
+
+.bulk-progress-card-stopped .bulk-progress-detail,
+.bulk-progress-card-error .bulk-progress-detail {
+ margin-top: 1.25rem;
+ padding: 1rem 1.25rem 1.25rem;
+}
+
+.bulk-progress-card-stopped .bulk-progress-main,
+.bulk-progress-card-error .bulk-progress-main {
+ padding: 1.25rem;
+}
+
+.bulk-progress-current {
+ color: #59617d;
+ font-size: 1.08rem;
+ line-height: 1.25;
+ margin-bottom: 0.95rem;
+}
+
+.bulk-progress-current strong {
+ color: #070b1a;
+ font-family: avenir-lt-w01_85-heavy1475544, avenir-lt-w05_85-heavy,
+ "Space Grotesk", sans-serif;
+ font-weight: 400;
+}
+
+.bulk-progress-workflow {
+ align-items: baseline;
+ display: flex;
+ flex-wrap: nowrap;
+ font-size: 0.95rem;
+ gap: 0.35rem;
+ line-height: 1.3;
+ margin-bottom: 0.95rem;
+ min-width: 0;
+}
+
+.bulk-progress-workflow-main {
+ display: flex;
+ flex: 1 1 auto;
+ gap: 0.45rem;
+ min-width: 0;
+}
+
+.bulk-progress-workflow-prefix {
+ color: #59617d;
+ flex: 0 0 var(--bulk-progress-detail-label-width);
+ white-space: nowrap;
+}
+
+.bulk-progress-workflow-link-group {
+ align-items: baseline;
+ display: inline-flex;
+ flex: 0 1 auto;
+ gap: 0.35rem;
+ min-width: 0;
+}
+
+.bulk-progress-workflow-elapsed {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.bulk-progress-detail-link {
+ color: #070b1a;
+ display: inline-block;
+ flex: 0 1 auto;
+ font-size: 0.95rem;
+ font-weight: 700;
+ min-width: 0;
+ overflow: hidden;
+ text-decoration: underline;
+ text-overflow: ellipsis;
+ text-underline-offset: 0.12em;
+ overflow-wrap: anywhere;
+}
+
+.bulk-progress-detail-link:link,
+.bulk-progress-detail-link:visited,
+.bulk-progress-detail-link:hover,
+.bulk-progress-detail-link:active,
+.bulk-progress-detail-link:focus {
+ color: #070b1a;
+}
+
+.bulk-progress-workflow-title {
+ max-width: 34ch;
+ vertical-align: bottom;
+ white-space: nowrap;
+}
+
+.bulk-progress-workflow-status {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.bulk-progress-input {
+ background: #fff;
+ border: 1px solid #e4e4e7;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ margin: 0 0 0.9rem;
+ overflow: hidden;
+ padding: 0.7rem 0.85rem;
+}
+
+.bulk-progress-input-text {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ line-height: 1.35;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+}
+
+.bulk-progress-input-text span,
+.bulk-progress-input > span {
+ color: #59617d;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.bulk-progress-last-completed {
+ align-items: baseline;
+ color: #59617d;
+ display: flex;
+ flex-wrap: nowrap;
+ font-size: 0.9rem;
+ gap: 0.45rem;
+ line-height: 1.35;
+ min-width: 0;
+}
+
+.bulk-progress-last-completed-label {
+ color: #59617d;
+ flex: 0 0 var(--bulk-progress-detail-label-width);
+ font-family: avenir-lt-w01_85-heavy1475544, avenir-lt-w05_85-heavy,
+ "Space Grotesk", sans-serif;
+ font-size: 0.8rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.bulk-progress-card-stopped .bulk-progress-last-completed-label,
+.bulk-progress-card-error .bulk-progress-last-completed-label {
+ color: #9d7b1f;
+}
+
+.bulk-progress-stop-pending {
+ background: #fff9e8;
+ border: 1px solid #efd893;
+ border-radius: 8px;
+ color: #6f5614;
+ font-size: 0.9rem;
+ line-height: 1.35;
+ margin: 0 0 0.9rem;
+ padding: 0.7rem 0.85rem;
+}
+
+.bulk-progress-actions {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.65rem;
+ padding: 0 1.25rem 1.25rem;
+}
+
+.bulk-progress-card-complete .bulk-progress-actions {
+ background: rgba(255, 255, 255, 0.35);
+ padding: 1.35rem 2.5rem;
+}
+
+.bulk-progress-action {
+ align-items: center;
+ display: inline-flex;
+ gap: 0.55rem;
+ justify-content: center;
+ margin: 0;
+ white-space: normal;
+}
+
+.bulk-progress-complete-header {
+ align-items: center;
+ display: flex;
+ gap: 1rem;
+ padding: 1.35rem 2.5rem;
+}
+
+.bulk-progress-status-icon {
+ background: #3f9438;
+ border-radius: 50%;
+ color: #fff;
+ display: grid;
+ flex: 0 0 auto;
+ height: 2rem;
+ place-items: center;
+ width: 2rem;
+}
+
+.bulk-progress-summary-grid {
+ border-top: 1px solid rgba(0, 0, 0, 0.08);
+ display: grid;
+ gap: 1.25rem;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ padding: 1.35rem 2.5rem;
+}
+
+.bulk-progress-stat-label {
+ color: #59617d;
+ font-family: avenir-lt-w01_85-heavy1475544, avenir-lt-w05_85-heavy,
+ "Space Grotesk", sans-serif;
+ font-size: 0.85rem;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.bulk-progress-stat-value {
+ font-family: avenir-lt-w01_85-heavy1475544, avenir-lt-w05_85-heavy,
+ "Space Grotesk", sans-serif;
+ font-size: 1.55rem;
+ line-height: 1.2;
+ margin: 0.45rem 0;
+}
+
+.bulk-progress-stat-caption {
+ font-size: 0.82rem;
+ line-height: 1.3;
+}
+
+@keyframes bulk-progress-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 50% {
+ opacity: 0.35;
+ transform: scale(0.82);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bulk-progress-card-running .bulk-progress-dot {
+ animation: none;
+ }
+
+ .bulk-progress-ring-bar {
+ transition: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .bulk-progress-card-running {
+ padding: 1.25rem;
+ }
+
+ .bulk-progress-running-header {
+ align-items: flex-start;
+ }
+
+ .bulk-progress-main {
+ align-items: center;
+ }
+
+ .bulk-progress-ring {
+ height: 78px;
+ width: 78px;
+ }
+
+ .bulk-progress-ring > div {
+ height: 60px;
+ width: 60px;
+ }
+
+ .bulk-progress-ring strong {
+ font-size: 1rem;
+ }
+
+ .bulk-progress-headline,
+ .bulk-progress-stat-value {
+ font-size: 1.45rem;
+ }
+
+ .bulk-progress-summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .bulk-progress-complete-header,
+ .bulk-progress-summary-grid,
+ .bulk-progress-card-complete .bulk-progress-actions {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+ }
+}
+
+@media (max-width: 540px) {
+ .bulk-progress-running-header {
+ flex-direction: column;
+ }
+}
diff --git a/gooey-gui/app/components/bulkProgress/BulkProgressCard.tsx b/gooey-gui/app/components/bulkProgress/BulkProgressCard.tsx
new file mode 100644
index 000000000..0088981b8
--- /dev/null
+++ b/gooey-gui/app/components/bulkProgress/BulkProgressCard.tsx
@@ -0,0 +1,342 @@
+import { useEffect, useState } from "react";
+
+import { buildCardModel, snapshotTicksElapsed } from "./bulkProgressCardModel";
+import { formatCredits, formatElapsed } from "./bulkProgressFormat";
+import type {
+ BulkProgressCardProps,
+ DetailDisplay,
+ WorkflowDisplay,
+} from "./bulkProgress.types";
+
+import "./BulkProgressCard.css";
+
+export function BulkProgressCard({
+ snapshot,
+ rerunAllKey,
+}: BulkProgressCardProps) {
+ const liveElapsedSeconds = useLiveElapsedSeconds(
+ snapshot.elapsedSeconds,
+ snapshotTicksElapsed(snapshot)
+ );
+ const model = buildCardModel(snapshot, liveElapsedSeconds);
+
+ if (model.kind === "complete") {
+ return (
+
+
+
+
+ all completed
+
+
+ {model.rowsCaption}
+
+
+ {model.averageCredits}
+
+
+ {model.averageRunTime}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {model.marker === "stop" && (
+
+ )}
+ {model.marker === "dot" && }
+ {model.title}
+
+
{model.headline}
+
+
+
+
+
+ {model.detail ?
: null}
+
+
+ );
+}
+
+function RerunAllActions({
+ rerunAllKey,
+ showRerun,
+}: {
+ rerunAllKey?: string | null;
+ showRerun: boolean;
+}) {
+ if (!rerunAllKey || !showRerun) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
+
+function useLiveElapsedSeconds(
+ elapsedSeconds: number | null | undefined,
+ isTicking: boolean
+) {
+ const [liveElapsedSeconds, setLiveElapsedSeconds] = useState(elapsedSeconds);
+
+ useEffect(() => {
+ setLiveElapsedSeconds(elapsedSeconds);
+ }, [elapsedSeconds]);
+
+ useEffect(() => {
+ if (!isTicking || elapsedSeconds == null) {
+ return;
+ }
+
+ const startedAt = Date.now() - elapsedSeconds * 1000;
+ const intervalId = window.setInterval(() => {
+ setLiveElapsedSeconds((Date.now() - startedAt) / 1000);
+ }, 1000);
+
+ return () => window.clearInterval(intervalId);
+ }, [elapsedSeconds, isTicking]);
+
+ return liveElapsedSeconds;
+}
+
+function SummaryStat({
+ label,
+ value,
+ children,
+}: {
+ label: string;
+ value: number | string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
{label}
+
{value}
+
{children}
+
+ );
+}
+
+function ProgressRing({
+ ringPercent,
+ ringLabel,
+ ringAccent,
+}: {
+ ringPercent: number;
+ ringLabel: string;
+ ringAccent: string;
+}) {
+ const circumference = 251.33;
+ const boundedRingPercent = Math.max(Math.min(ringPercent, 100), 0);
+ const remaining = circumference - (circumference * boundedRingPercent) / 100;
+
+ return (
+
+
+
+ {ringLabel}
+
+
+ );
+}
+
+function MetaRow({ parts }: { parts: string[] }) {
+ const nodes: React.ReactNode[] = [];
+ parts.forEach((part, index) => {
+ if (index) {
+ nodes.push(
+
+ ·
+
+ );
+ }
+ nodes.push(
+
+ {part}
+
+ );
+ });
+
+ return {nodes}
;
+}
+
+function ProgressDetail({ detail }: { detail: DetailDisplay }) {
+ let inputAudioSafeHref: string | null = null;
+ if (detail.inputAudioUrl) {
+ try {
+ const parsed = new URL(detail.inputAudioUrl);
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
+ inputAudioSafeHref = parsed.href;
+ }
+ } catch {
+ inputAudioSafeHref = null;
+ }
+ }
+
+ return (
+
+
+ {detail.rowLabel}
+
+ {detail.showStoppingMessage ? (
+
+ We're trying our best to cancel this run, please be patient.
+
+ ) : null}
+ {detail.workflow ? (
+
+ ) : null}
+ {detail.inputPrompt ? (
+
+
+ input_prompt: {detail.inputPrompt}
+
+
+ ) : null}
+ {detail.inputAudioUrl ? (
+
+
input_audio:{" "}
+ {inputAudioSafeHref ? (
+
+ View audio →
+
+ ) : (
+
{detail.inputAudioUrl}
+ )}
+
+ ) : null}
+ {detail.lastCompleted ? (
+
+ ) : null}
+
+ );
+}
+
+function WorkflowRow({ workflow }: { workflow: WorkflowDisplay }) {
+ const showElapsed = workflow.elapsedSeconds != null;
+ const liveElapsedSeconds = useLiveElapsedSeconds(
+ workflow.elapsedSeconds ?? 0,
+ showElapsed
+ );
+
+ return (
+
+
+ {workflow.prefix}
+
+
+ {workflow.title}
+
+ {showElapsed ? (
+
+ · {formatElapsed(liveElapsedSeconds ?? 0)}
+
+ ) : null}
+
+
+ {workflow.failedAt != null ? (
+
+ · failed at {formatElapsed(workflow.failedAt)}
+
+ ) : null}
+
+ );
+}
+
+function LastCompleted({
+ lastCompleted,
+}: {
+ lastCompleted: NonNullable;
+}) {
+ const { credits, runTimeSeconds, title, url } = lastCompleted;
+ return (
+
+
Last completed
+
+
+ {title}
+
+ {runTimeSeconds != null || credits != null ? (
+
+ {credits != null ? (
+ · {formatCredits(credits)}
+ ) : null}
+ {runTimeSeconds != null ? (
+ · {formatElapsed(runTimeSeconds)}
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
diff --git a/gooey-gui/app/components/bulkProgress/bulkProgress.types.ts b/gooey-gui/app/components/bulkProgress/bulkProgress.types.ts
new file mode 100644
index 000000000..cd68f0557
--- /dev/null
+++ b/gooey-gui/app/components/bulkProgress/bulkProgress.types.ts
@@ -0,0 +1,90 @@
+// Keep in sync with widgets/bulk_progress_display.py (BulkProgressSnapshot).
+
+export type BulkRunnerRunState =
+ | "running"
+ | "stopping"
+ | "evaluating"
+ | "complete"
+ | "error"
+ | "stopped";
+
+export type BulkProgressSnapshot = {
+ runState: BulkRunnerRunState;
+ elapsedSeconds?: number | null;
+ completedUnitRuns: number;
+ totalUnitRuns: number;
+ totalRows: number;
+ currentRowNumber: number;
+ currentWorkflowNumber: number;
+ totalWorkflows: number;
+ currentWorkflowTitle: string;
+ currentWorkflowUrl: string;
+ currentWorkflowRunTimeSeconds?: number | null;
+ creditsUsed?: number | null;
+ totalEvalRuns?: number;
+ evalCurrent?: number;
+ evalTotal?: number;
+ evalWorkflowTitle?: string;
+ inputPrompt?: string;
+ inputAudioUrl?: string;
+ lastCompletedWorkflowTitle?: string;
+ lastCompletedWorkflowUrl?: string;
+ lastCompletedRunTimeSeconds?: number | null;
+ lastCompletedCredits?: number | null;
+};
+
+export type BulkProgressCardProps = {
+ snapshot: BulkProgressSnapshot;
+ rerunAllKey?: string | null;
+};
+
+export type WorkflowDisplay = {
+ prefix: string;
+ title: string;
+ url: string;
+ failedAt: number | null;
+ elapsedSeconds: number | null;
+};
+
+export type DetailDisplay = {
+ rowLabel: string;
+ showStoppingMessage: boolean;
+ workflow: WorkflowDisplay | null;
+ inputPrompt?: string;
+ inputAudioUrl?: string;
+ lastCompleted?: {
+ title: string;
+ url: string;
+ credits?: number | null;
+ runTimeSeconds?: number | null;
+ };
+};
+
+export type CompleteCardModel = {
+ kind: "complete";
+ title: string;
+ totalRuns: number;
+ totalRows: number;
+ rowsCaption: string;
+ totalTime: string;
+ averageRunTime: string;
+ credits: string;
+ averageCredits: string;
+ showRerun: boolean;
+};
+
+export type ActiveCardModel = {
+ kind: "active";
+ cardClass: string;
+ title: string;
+ headline: string;
+ metaParts: string[];
+ marker: "dot" | "stop" | null;
+ ringPercent: number;
+ ringLabel: string;
+ ringAccent: string;
+ detail: DetailDisplay | null;
+ showRerun: boolean;
+};
+
+export type CardModel = CompleteCardModel | ActiveCardModel;
diff --git a/gooey-gui/app/components/bulkProgress/bulkProgressCardModel.ts b/gooey-gui/app/components/bulkProgress/bulkProgressCardModel.ts
new file mode 100644
index 000000000..f8111a3fc
--- /dev/null
+++ b/gooey-gui/app/components/bulkProgress/bulkProgressCardModel.ts
@@ -0,0 +1,344 @@
+import {
+ formatAverageCredits,
+ formatAverageRunTime,
+ formatCredits,
+ formatElapsed,
+} from "./bulkProgressFormat";
+import type {
+ ActiveCardModel,
+ BulkProgressSnapshot,
+ BulkRunnerRunState,
+ CardModel,
+ CompleteCardModel,
+ DetailDisplay,
+ WorkflowDisplay,
+} from "./bulkProgress.types";
+
+type ActiveRunState = Exclude;
+
+type ActiveRunStateProfile = {
+ title: string;
+ tickElapsed: boolean;
+ showRerun: boolean;
+ marker: ActiveCardModel["marker"];
+ cardClass: string;
+ ringAccent: string;
+};
+
+const ACTIVE_RUN_STATE_PROFILE: Record = {
+ running: {
+ title: "Running...",
+ tickElapsed: true,
+ showRerun: false,
+ marker: "dot",
+ cardClass: "running",
+ ringAccent: "#070b1a",
+ },
+ stopping: {
+ title: "Stopping...",
+ tickElapsed: true,
+ showRerun: false,
+ marker: "dot",
+ cardClass: "running",
+ ringAccent: "#070b1a",
+ },
+ evaluating: {
+ title: "Running evals...",
+ tickElapsed: true,
+ showRerun: false,
+ marker: "dot",
+ cardClass: "running",
+ ringAccent: "#070b1a",
+ },
+ error: {
+ title: "Bulk run failed",
+ tickElapsed: false,
+ showRerun: true,
+ marker: "dot",
+ cardClass: "error",
+ ringAccent: "#b42318",
+ },
+ stopped: {
+ title: "Bulk run stopped",
+ tickElapsed: false,
+ showRerun: true,
+ marker: "stop",
+ cardClass: "stopped",
+ ringAccent: "#9d7b1f",
+ },
+};
+
+export function buildCardModel(
+ snapshot: BulkProgressSnapshot,
+ liveElapsedSeconds: number | null | undefined
+): CardModel {
+ switch (snapshot.runState) {
+ case "complete":
+ return buildCompleteModel(snapshot);
+ case "evaluating":
+ return buildEvaluatingModel(snapshot, liveElapsedSeconds);
+ case "running":
+ return buildInProgressUnitRunModel(snapshot, {
+ runState: "running",
+ liveElapsedSeconds,
+ });
+ case "stopping":
+ return buildInProgressUnitRunModel(snapshot, {
+ runState: "stopping",
+ liveElapsedSeconds,
+ });
+ case "stopped":
+ return buildTerminalUnitRunModel(snapshot, "stopped");
+ case "error":
+ return buildTerminalUnitRunModel(snapshot, "error");
+ }
+}
+
+export function snapshotTicksElapsed(snapshot: BulkProgressSnapshot): boolean {
+ return (
+ snapshot.runState !== "complete" &&
+ ACTIVE_RUN_STATE_PROFILE[snapshot.runState].tickElapsed
+ );
+}
+
+function buildCompleteModel(snapshot: BulkProgressSnapshot): CompleteCardModel {
+ return {
+ kind: "complete",
+ title: "Bulk run complete",
+ totalRuns: snapshot.totalUnitRuns,
+ totalRows: snapshot.totalRows,
+ rowsCaption: `× ${snapshot.totalWorkflows} workflows`,
+ totalTime:
+ snapshot.elapsedSeconds != null
+ ? formatElapsed(snapshot.elapsedSeconds)
+ : "-",
+ averageRunTime: formatAverageRunTime(snapshot),
+ credits:
+ snapshot.creditsUsed != null ? String(snapshot.creditsUsed) : "-",
+ averageCredits: formatAverageCredits(snapshot),
+ showRerun: true,
+ };
+}
+
+function buildEvaluatingModel(
+ snapshot: BulkProgressSnapshot,
+ liveElapsedSeconds: number | null | undefined
+): ActiveCardModel {
+ const profile = ACTIVE_RUN_STATE_PROFILE.evaluating;
+ const evalCounts =
+ snapshot.evalCurrent != null && snapshot.evalTotal != null
+ ? { current: snapshot.evalCurrent, total: snapshot.evalTotal }
+ : null;
+ const ring = getEvalRingDisplay(evalCounts);
+ const metaParts: string[] = [];
+
+ if (evalCounts) {
+ metaParts.push(`${evalCounts.current} of ${evalCounts.total}`);
+ }
+ appendCreditsAndElapsed(metaParts, snapshot, liveElapsedSeconds);
+
+ return activeFromProfile(profile, {
+ headline: snapshot.evalWorkflowTitle || "Eval workflow",
+ metaParts,
+ ringPercent: ring.ringPercent,
+ ringLabel: ring.ringLabel,
+ detail: null,
+ });
+}
+
+function buildInProgressUnitRunModel(
+ snapshot: BulkProgressSnapshot,
+ variant: {
+ runState: "running" | "stopping";
+ liveElapsedSeconds: number | null | undefined;
+ }
+): ActiveCardModel {
+ const profile = ACTIVE_RUN_STATE_PROFILE[variant.runState];
+ const ring = unitRunRingFields(snapshot);
+ const metaParts = [
+ `${snapshot.completedUnitRuns} of ${snapshot.totalUnitRuns} total runs`,
+ ];
+ appendCreditsAndElapsed(metaParts, snapshot, variant.liveElapsedSeconds);
+
+ return activeFromProfile(profile, {
+ ...ring,
+ metaParts,
+ detail: buildDetail(snapshot, {
+ rowLabel: `Processing row ${snapshot.currentRowNumber} of ${snapshot.totalRows}`,
+ showStoppingMessage: variant.runState === "stopping",
+ capitalized: false,
+ failedAt: null,
+ workflowElapsedSeconds: 0,
+ }),
+ });
+}
+
+function buildTerminalUnitRunModel(
+ snapshot: BulkProgressSnapshot,
+ runState: "stopped" | "error"
+): ActiveCardModel {
+ const profile = ACTIVE_RUN_STATE_PROFILE[runState];
+ const ring = unitRunRingFields(snapshot);
+ const remaining = Math.max(
+ snapshot.totalUnitRuns - snapshot.completedUnitRuns,
+ 0
+ );
+ const metaParts = [
+ `${snapshot.completedUnitRuns} of ${snapshot.totalUnitRuns} Total runs (${remaining} remaining)`,
+ ];
+ const elapsed = runState === "stopped" ? snapshot.elapsedSeconds : undefined;
+ appendCreditsAndElapsed(metaParts, snapshot, elapsed);
+ const rowLabel =
+ runState === "error"
+ ? `Failed on row ${snapshot.currentRowNumber} of ${snapshot.totalRows}`
+ : `Started row ${snapshot.currentRowNumber} of ${snapshot.totalRows}`;
+
+ return activeFromProfile(profile, {
+ ...ring,
+ metaParts,
+ detail: buildDetail(snapshot, {
+ rowLabel,
+ showStoppingMessage: false,
+ capitalized: true,
+ failedAt:
+ runState === "error"
+ ? snapshot.currentWorkflowRunTimeSeconds ?? null
+ : null,
+ }),
+ });
+}
+
+function activeFromProfile(
+ profile: ActiveRunStateProfile,
+ fields: {
+ headline: string;
+ metaParts: string[];
+ ringPercent: number;
+ ringLabel: string;
+ detail: DetailDisplay | null;
+ }
+): ActiveCardModel {
+ return {
+ kind: "active",
+ cardClass: profile.cardClass,
+ title: profile.title,
+ headline: fields.headline,
+ metaParts: fields.metaParts,
+ marker: profile.marker,
+ ringPercent: fields.ringPercent,
+ ringLabel: fields.ringLabel,
+ ringAccent: profile.ringAccent,
+ detail: fields.detail,
+ showRerun: profile.showRerun,
+ };
+}
+
+function appendCreditsAndElapsed(
+ metaParts: string[],
+ snapshot: BulkProgressSnapshot,
+ elapsedSeconds: number | null | undefined
+) {
+ if (snapshot.creditsUsed != null) {
+ metaParts.push(formatCredits(snapshot.creditsUsed));
+ }
+ if (elapsedSeconds != null) {
+ metaParts.push(formatElapsed(elapsedSeconds));
+ }
+}
+
+function buildDetail(
+ snapshot: BulkProgressSnapshot,
+ {
+ rowLabel,
+ showStoppingMessage,
+ capitalized,
+ failedAt,
+ workflowElapsedSeconds = null,
+ }: {
+ rowLabel: string;
+ showStoppingMessage: boolean;
+ capitalized: boolean;
+ failedAt: number | null;
+ workflowElapsedSeconds?: number | null;
+ }
+): DetailDisplay {
+ const currentTitle = snapshot.currentWorkflowTitle.trim();
+ const lastTitle = snapshot.lastCompletedWorkflowTitle?.trim();
+ const lastUrl = snapshot.lastCompletedWorkflowUrl;
+ let workflow: WorkflowDisplay | null = null;
+
+ if (currentTitle && snapshot.currentWorkflowUrl) {
+ const workflowNumber = Math.max(snapshot.currentWorkflowNumber, 1);
+ let prefix: string;
+ if (capitalized) {
+ prefix = `Workflow ${workflowNumber} of ${snapshot.totalWorkflows}:`;
+ } else {
+ prefix = `workflow ${workflowNumber} of ${snapshot.totalWorkflows}`;
+ }
+ workflow = {
+ prefix,
+ title: currentTitle,
+ url: snapshot.currentWorkflowUrl,
+ failedAt,
+ elapsedSeconds: workflowElapsedSeconds,
+ };
+ }
+
+ return {
+ rowLabel,
+ showStoppingMessage,
+ workflow,
+ inputPrompt: snapshot.inputPrompt,
+ inputAudioUrl: snapshot.inputAudioUrl,
+ lastCompleted:
+ lastTitle && lastUrl
+ ? {
+ title: lastTitle,
+ url: lastUrl,
+ credits: snapshot.lastCompletedCredits,
+ runTimeSeconds: snapshot.lastCompletedRunTimeSeconds,
+ }
+ : undefined,
+ };
+}
+
+function getEvalRingDisplay(
+ evalCounts: { current: number; total: number } | null
+): { ringPercent: number; ringLabel: string } {
+ if (!evalCounts) {
+ return { ringPercent: 0, ringLabel: "…" };
+ }
+ if (!evalCounts.total) {
+ return {
+ ringPercent: 0,
+ ringLabel: `${evalCounts.current}/${evalCounts.total}`,
+ };
+ }
+ const completedEvals = Math.max(evalCounts.current - 1, 0);
+ const ringPercent = Math.min(
+ Math.max(Math.round((completedEvals / evalCounts.total) * 100), 0),
+ 100
+ );
+ return {
+ ringPercent,
+ ringLabel: `${completedEvals}/${evalCounts.total}`,
+ };
+}
+
+function unitRunRingFields(snapshot: BulkProgressSnapshot) {
+ let ringPercent = 0;
+ if (snapshot.totalUnitRuns) {
+ ringPercent = Math.min(
+ Math.max(
+ Math.round((snapshot.completedUnitRuns / snapshot.totalUnitRuns) * 100),
+ 0
+ ),
+ 100
+ );
+ }
+ return {
+ ringPercent,
+ ringLabel: `${ringPercent}%`,
+ headline: `${ringPercent}% Completed`,
+ };
+}
diff --git a/gooey-gui/app/components/bulkProgress/bulkProgressFormat.ts b/gooey-gui/app/components/bulkProgress/bulkProgressFormat.ts
new file mode 100644
index 000000000..8ab553b4b
--- /dev/null
+++ b/gooey-gui/app/components/bulkProgress/bulkProgressFormat.ts
@@ -0,0 +1,43 @@
+import type { BulkProgressSnapshot } from "./bulkProgress.types";
+
+export function formatElapsed(seconds: number) {
+ if (seconds < 60) {
+ return `${Math.round(seconds)}s`;
+ }
+
+ const roundedSeconds = Math.round(seconds) % 60;
+ const totalMinutes = Math.floor(Math.round(seconds) / 60);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ if (hours) {
+ return `${hours}h ${minutes}m`;
+ }
+ if (totalMinutes) {
+ return `${totalMinutes}m ${roundedSeconds}s`;
+ }
+ return `${roundedSeconds}s`;
+}
+
+export function formatCredits(credits: number | null | undefined) {
+ if (credits == null) {
+ return "-";
+ }
+ return `${credits} Cr`;
+}
+
+export function formatAverageCredits(snapshot: BulkProgressSnapshot) {
+ const totalRuns = snapshot.totalUnitRuns + (snapshot.totalEvalRuns || 0);
+ if (snapshot.creditsUsed == null || totalRuns <= 0) {
+ return "-";
+ }
+ return `${(snapshot.creditsUsed / totalRuns).toFixed(1)} Cr / run`;
+}
+
+export function formatAverageRunTime(snapshot: BulkProgressSnapshot) {
+ if (snapshot.elapsedSeconds == null || snapshot.totalUnitRuns <= 0) {
+ return "-";
+ }
+ return `${(snapshot.elapsedSeconds / snapshot.totalUnitRuns).toFixed(
+ 1
+ )}s avg / run`;
+}
diff --git a/gooey-gui/app/components/index.ts b/gooey-gui/app/components/index.ts
index afd1d5cd4..d20a92896 100644
--- a/gooey-gui/app/components/index.ts
+++ b/gooey-gui/app/components/index.ts
@@ -7,6 +7,7 @@ export type CustomComponentProps = {
state: Record;
};
+export * from "./bulkProgress/BulkProgressCard";
export * from "./ComposioAuthRequired";
export * from "./ExploreBuilderPrompt";
export * from "./ForgotPasswordForm";
diff --git a/recipes/BulkRunner.py b/recipes/BulkRunner.py
index b91b547f1..35c80184f 100644
--- a/recipes/BulkRunner.py
+++ b/recipes/BulkRunner.py
@@ -33,6 +33,12 @@
edit_done_button,
)
from recipes.DocSearch import render_documents
+from widgets.bulk_progress_display import render_bulk_runner_progress
+from widgets.bulk_progress_state import (
+ BulkEvalProgress,
+ BulkProgress,
+ BulkProgressTracker,
+)
if typing.TYPE_CHECKING:
import pandas as pd
@@ -87,6 +93,8 @@ class RequestModel(BasePage.RequestModel):
class ResponseModel(BaseModel):
output_documents: list[HttpUrlStr]
+ bulk_progress: BulkProgress | None = None
+ eval_progress: BulkEvalProgress | None = None
eval_runs: list[HttpUrlStr] | None = Field(
None,
@@ -242,6 +250,10 @@ def render_run_preview_output(self, state: dict):
render_documents(state)
def render_output(self):
+ render_bulk_runner_progress(
+ is_cancelled=bool(self.current_sr and self.current_sr.is_cancelled),
+ )
+
eval_runs = gui.session_state.get("eval_runs")
if eval_runs:
@@ -275,6 +287,36 @@ def render_output(self):
)
gui.data_table(file)
+ def _render_running_output(self):
+ if gui.session_state.get("bulk_progress"):
+ return
+
+ super()._render_running_output()
+
+ def render_is_cancelled(self):
+ if gui.session_state.get("bulk_progress"):
+ return
+ super().render_is_cancelled()
+
+ def get_run_cost_display(self) -> str:
+ progress = gui.session_state.get("bulk_progress")
+ if not progress:
+ return ""
+
+ run_cost = progress.get("credits_used") or 0
+ url = self.get_credits_click_url()
+ ret = f'Run cost = {run_cost} credits'
+
+ cost_note = self.get_cost_note()
+ if cost_note:
+ ret += f" ({cost_note.strip()})"
+
+ additional_notes = self.additional_notes()
+ if additional_notes:
+ ret += f" \n{additional_notes}"
+
+ return ret
+
def run_v2(
self,
request: "BulkRunnerPage.RequestModel",
@@ -284,9 +326,17 @@ def run_v2(
response.output_documents = []
array_columns = set()
+ dfs = [read_df_any(doc) for doc in request.documents]
+ total_rows = sum(len(df) for df in dfs)
+ total_row_groups = sum(1 for df in dfs for _ in slice_request_df(df, request))
+ progress = BulkProgressTracker(
+ total_rows=total_rows,
+ total_row_groups=total_row_groups,
+ total_workflows=len(request.run_urls),
+ )
- for doc_ix, doc in enumerate(request.documents):
- df = read_df_any(doc)
+ row_offset = 0
+ for doc_ix, df in enumerate(dfs):
in_recs = df.to_dict(orient="records")
out_recs = []
@@ -297,28 +347,30 @@ def run_v2(
)
response.output_documents.append(f)
- df_slices = list(slice_request_df(df, request))
- for slice_ix, (df_ix, arr_len) in enumerate(df_slices):
+ for df_ix, arr_len in slice_request_df(df, request):
rec_ix = len(out_recs)
out_recs.extend(in_recs[df_ix : df_ix + arr_len])
+ current_row_number = row_offset + min(df_ix + arr_len, len(df))
used_col_names = set()
for url_ix, request_body, page_cls, sr, pr in build_requests_for_df(
df, request, df_ix, arr_len, array_columns
):
- progress = round(
- (slice_ix + url_ix)
- / (len(df_slices) + len(request.run_urls))
- * 100
- )
- yield f"{progress}%"
-
result, sr = sr.submit_api_call(
workspace=self.current_workspace,
current_user=self.request.user,
request_body=request_body,
parent_pr=pr,
)
+ yield progress.workflow_started(
+ response,
+ current_row_number=current_row_number,
+ workflow_number=url_ix + 1,
+ page_cls=page_cls,
+ sr=sr,
+ pr=pr,
+ request_body=request_body,
+ )
sr.wait_for_celery_result(result)
state = sr.to_dict()
@@ -373,14 +425,34 @@ def run_v2(
content_type="text/csv",
)
response.output_documents[doc_ix] = f
+ yield progress.workflow_completed(
+ response,
+ page_cls=page_cls,
+ sr=sr,
+ pr=pr,
+ request_body=request_body,
+ arr_len=arr_len,
+ workflow_run_time_seconds=state["run_time"],
+ workflow_credits=sr.price,
+ error_msg=sr.error_msg,
+ )
+
+ row_offset += len(df)
if not request.eval_urls:
return
response.eval_runs = []
- for url in request.eval_urls:
+ total_evals = len(request.eval_urls)
+ for eval_ix, url in enumerate(request.eval_urls):
page_cls, sr, pr = url_to_runs(url)
- yield f"Running {get_title_breadcrumbs(page_cls, sr, pr).title_with_prefix()}..."
+ title = get_title_breadcrumbs(page_cls, sr, pr).title_with_prefix()
+ yield progress.eval_started(
+ response,
+ current=eval_ix + 1,
+ total=total_evals,
+ workflow_title=title,
+ )
request_body = page_cls.RequestModel(
documents=response.output_documents,
array_columns=array_columns or None,
@@ -393,6 +465,9 @@ def run_v2(
)
sr.wait_for_celery_result(result)
response.eval_runs.append(sr.get_app_url())
+ yield progress.eval_completed(response, eval_credits=sr.price)
+
+ progress.evals_completed(response)
def render_run_url_inputs(self, key: str, del_key: str, d: dict):
from daras_ai_v2.all_pages import all_home_pages
diff --git a/widgets/bulk_progress_display.py b/widgets/bulk_progress_display.py
new file mode 100644
index 000000000..6678df2f7
--- /dev/null
+++ b/widgets/bulk_progress_display.py
@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+import datetime
+from enum import Enum
+
+import gooey_gui as gui
+from typing_extensions import NotRequired, TypedDict
+
+from daras_ai_v2.base import BasePage, RecipeRunState, StateKeys
+from widgets.bulk_progress_state import (
+ BulkEvalProgress,
+ BulkProgress,
+ is_bulk_progress_complete,
+)
+
+BULK_RERUN_ALL_KEY = "-submit-workflow"
+
+
+class BulkRunnerRunState(str, Enum):
+ running = "running"
+ stopping = "stopping"
+ evaluating = "evaluating"
+ complete = "complete"
+ error = "error"
+ stopped = "stopped"
+
+
+# Keep in sync with gooey-gui/app/components/bulkProgress/bulkProgress.types.ts (BulkProgressSnapshot).
+class BulkProgressSnapshot(TypedDict, total=False):
+ runState: BulkRunnerRunState
+ elapsedSeconds: float | None
+ completedUnitRuns: int
+ totalUnitRuns: int
+ totalRows: int
+ currentRowNumber: int
+ currentWorkflowNumber: int
+ totalWorkflows: int
+ currentWorkflowTitle: str
+ currentWorkflowUrl: str
+ currentWorkflowRunTimeSeconds: NotRequired[float | None]
+ creditsUsed: NotRequired[int]
+ totalEvalRuns: NotRequired[int]
+ evalCurrent: NotRequired[int]
+ evalTotal: NotRequired[int]
+ evalWorkflowTitle: NotRequired[str]
+ inputPrompt: NotRequired[str]
+ inputAudioUrl: NotRequired[str]
+ lastCompletedWorkflowTitle: NotRequired[str]
+ lastCompletedWorkflowUrl: NotRequired[str]
+ lastCompletedRunTimeSeconds: NotRequired[float | None]
+ lastCompletedCredits: NotRequired[int | None]
+
+
+def render_bulk_runner_progress(*, is_cancelled: bool) -> None:
+ session = gui.session_state
+ progress = session.get("bulk_progress")
+ if not progress:
+ return
+
+ run_time = session.get(StateKeys.run_time)
+ elapsed_seconds = None
+ if run_time is not None:
+ if isinstance(run_time, datetime.timedelta):
+ elapsed_seconds = run_time.total_seconds()
+ else:
+ elapsed_seconds = run_time
+
+ snapshot = build_bulk_progress_snapshot(
+ progress=progress,
+ is_cancelled=is_cancelled,
+ recipe_run_state=BasePage.get_run_state(session),
+ elapsed_seconds=elapsed_seconds,
+ eval_progress=session.get("eval_progress"),
+ )
+ if not snapshot:
+ return
+
+ gui.component(
+ "BulkProgressCard",
+ snapshot=snapshot,
+ rerunAllKey=BULK_RERUN_ALL_KEY,
+ )
+
+
+def build_bulk_progress_snapshot(
+ *,
+ progress: BulkProgress,
+ is_cancelled: bool,
+ recipe_run_state: RecipeRunState,
+ elapsed_seconds: float | None,
+ eval_progress: BulkEvalProgress | None = None,
+) -> BulkProgressSnapshot | None:
+ run_state = bulk_snapshot_run_state(
+ progress=progress,
+ is_cancelled=is_cancelled,
+ recipe_run_state=recipe_run_state,
+ )
+ if not run_state:
+ return None
+
+ snapshot: BulkProgressSnapshot = {
+ "runState": run_state,
+ "elapsedSeconds": elapsed_seconds,
+ "completedUnitRuns": progress["completed_unit_runs"],
+ "totalUnitRuns": progress["total_unit_runs"],
+ "totalRows": progress["total_rows"],
+ "currentRowNumber": progress["current_row_number"],
+ "currentWorkflowNumber": progress["current_workflow_number"],
+ "totalWorkflows": progress["total_workflows"],
+ "currentWorkflowTitle": progress["workflow_title"],
+ "currentWorkflowUrl": progress["workflow_url"],
+ }
+ if "workflow_run_time_seconds" in progress:
+ snapshot["currentWorkflowRunTimeSeconds"] = progress[
+ "workflow_run_time_seconds"
+ ]
+ if "credits_used" in progress:
+ snapshot["creditsUsed"] = progress["credits_used"]
+ if "total_eval_runs" in progress:
+ snapshot["totalEvalRuns"] = progress["total_eval_runs"]
+ if run_state == BulkRunnerRunState.evaluating and eval_progress:
+ snapshot["evalCurrent"] = eval_progress["current"]
+ snapshot["evalTotal"] = eval_progress["total"]
+ snapshot["evalWorkflowTitle"] = eval_progress["workflow_title"]
+ if input_prompt := progress.get("input_prompt"):
+ snapshot["inputPrompt"] = input_prompt
+ if input_audio := progress.get("input_audio"):
+ snapshot["inputAudioUrl"] = input_audio
+ if progress.get("last_completed_workflow_title") and progress.get(
+ "last_completed_workflow_url"
+ ):
+ snapshot["lastCompletedWorkflowTitle"] = progress[
+ "last_completed_workflow_title"
+ ]
+ snapshot["lastCompletedWorkflowUrl"] = progress["last_completed_workflow_url"]
+ if "last_completed_run_time_seconds" in progress:
+ snapshot["lastCompletedRunTimeSeconds"] = progress[
+ "last_completed_run_time_seconds"
+ ]
+ if "last_completed_credits" in progress:
+ snapshot["lastCompletedCredits"] = progress["last_completed_credits"]
+ return snapshot
+
+
+def bulk_snapshot_run_state(
+ *,
+ progress: BulkProgress,
+ is_cancelled: bool,
+ recipe_run_state: RecipeRunState,
+) -> BulkRunnerRunState | None:
+ is_active = recipe_run_state in {RecipeRunState.starting, RecipeRunState.running}
+ if is_active and is_cancelled:
+ return BulkRunnerRunState.stopping
+
+ if recipe_run_state == RecipeRunState.failed:
+ return BulkRunnerRunState.error
+
+ bulk_complete = is_bulk_progress_complete(progress)
+ if not bulk_complete:
+ if is_active:
+ return BulkRunnerRunState.running
+ return BulkRunnerRunState.stopped
+
+ if progress["phase"] == "evaluating":
+ return BulkRunnerRunState.evaluating
+
+ return BulkRunnerRunState.complete
diff --git a/widgets/bulk_progress_state.py b/widgets/bulk_progress_state.py
new file mode 100644
index 000000000..27b005e55
--- /dev/null
+++ b/widgets/bulk_progress_state.py
@@ -0,0 +1,283 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from typing_extensions import NotRequired, TypedDict
+
+from bots.models import SavedRun
+from daras_ai_v2.base import BasePage
+from daras_ai_v2.breadcrumbs import get_title_breadcrumbs
+
+BulkProgressPhase = Literal["running", "evaluating", "complete"]
+BULK_INPUT_PROMPT_PREVIEW_CHARS = 160
+
+
+class BulkEvalProgress(TypedDict):
+ current: int
+ total: int
+ workflow_title: str
+
+
+class BulkProgressCounts(TypedDict):
+ completed_unit_runs: int
+ total_unit_runs: int
+ completed_row_groups: int
+ total_row_groups: int
+ completed_rows: int
+ total_rows: int
+ current_row_number: int
+ current_workflow_number: int
+ total_workflows: int
+
+
+class BulkProgress(BulkProgressCounts):
+ phase: BulkProgressPhase
+ workflow_title: str
+ workflow_url: str
+ input_prompt: str
+ input_audio: NotRequired[str]
+ credits_used: NotRequired[int]
+ workflow_run_time_seconds: NotRequired[float]
+ workflow_credits: NotRequired[int]
+ total_eval_runs: NotRequired[int]
+ last_completed_workflow_title: NotRequired[str]
+ last_completed_workflow_url: NotRequired[str]
+ last_completed_run_time_seconds: NotRequired[float]
+ last_completed_credits: NotRequired[int]
+ error_msg: NotRequired[str]
+
+
+class BulkProgressTracker:
+ def __init__(
+ self,
+ *,
+ total_rows: int,
+ total_row_groups: int,
+ total_workflows: int,
+ ):
+ self.counts: BulkProgressCounts = {
+ "completed_unit_runs": 0,
+ "total_unit_runs": total_row_groups * total_workflows,
+ "completed_row_groups": 0,
+ "total_row_groups": total_row_groups,
+ "completed_rows": 0,
+ "total_rows": total_rows,
+ "current_row_number": 0,
+ "current_workflow_number": 1,
+ "total_workflows": total_workflows,
+ }
+ self.credits_used = 0
+ self.snapshot: BulkProgress | None = None
+
+ def workflow_started(
+ self,
+ response,
+ *,
+ current_row_number: int,
+ workflow_number: int,
+ page_cls: type[BasePage],
+ sr: SavedRun,
+ pr,
+ request_body: dict,
+ ) -> tuple[str, dict[str, BulkProgress]]:
+ self.counts.update(
+ current_row_number=current_row_number,
+ current_workflow_number=workflow_number,
+ )
+ return self._emit_bulk_progress(
+ response,
+ page_cls=page_cls,
+ sr=sr,
+ pr=pr,
+ request_body=request_body,
+ phase="running",
+ current_workflow_completed=False,
+ )
+
+ def workflow_completed(
+ self,
+ response,
+ *,
+ page_cls: type[BasePage],
+ sr: SavedRun,
+ pr,
+ request_body: dict,
+ arr_len: int,
+ workflow_run_time_seconds: float | None,
+ workflow_credits: int | None,
+ error_msg: str | None,
+ ) -> tuple[str, dict[str, BulkProgress]]:
+ self.counts["completed_unit_runs"] += 1
+ if self.counts["current_workflow_number"] == self.counts["total_workflows"]:
+ self.counts["completed_row_groups"] += 1
+ self.counts["completed_rows"] += arr_len
+ self.credits_used += workflow_credits or 0
+
+ return self._emit_bulk_progress(
+ response,
+ page_cls=page_cls,
+ sr=sr,
+ pr=pr,
+ request_body=request_body,
+ phase="running",
+ credits_used=self.credits_used,
+ workflow_run_time_seconds=workflow_run_time_seconds,
+ workflow_credits=workflow_credits,
+ error_msg=error_msg,
+ )
+
+ def eval_started(
+ self,
+ response,
+ *,
+ current: int,
+ total: int,
+ workflow_title: str,
+ ) -> str:
+ response.eval_progress = {
+ "current": current,
+ "total": total,
+ "workflow_title": workflow_title,
+ }
+ if self.snapshot:
+ self.snapshot["phase"] = "evaluating"
+ self.snapshot["total_eval_runs"] = total
+ response.bulk_progress = self.snapshot
+ return f"Running {workflow_title}..."
+
+ def eval_completed(
+ self,
+ response,
+ *,
+ eval_credits: int | None,
+ ) -> tuple[str, dict[str, BulkProgress]] | None:
+ self.credits_used += eval_credits or 0
+ if not self.snapshot:
+ return None
+
+ self.snapshot["credits_used"] = self.credits_used
+ response.bulk_progress = self.snapshot
+ return (
+ f"{bulk_progress_percent(self.snapshot)}% Completed",
+ {"bulk_progress": self.snapshot},
+ )
+
+ def evals_completed(self, response) -> None:
+ response.eval_progress = None
+ if self.snapshot:
+ self.snapshot["phase"] = "complete"
+ response.bulk_progress = self.snapshot
+
+ def _emit_bulk_progress(
+ self,
+ response,
+ *,
+ page_cls: type[BasePage],
+ sr: SavedRun,
+ pr,
+ request_body: dict,
+ phase: BulkProgressPhase,
+ current_workflow_completed: bool = True,
+ credits_used: int | None = None,
+ workflow_run_time_seconds: float | None = None,
+ workflow_credits: int | None = None,
+ error_msg: str | None = None,
+ ) -> tuple[str, dict[str, BulkProgress]]:
+ self.snapshot = build_bulk_progress(
+ progress=self.counts,
+ page_cls=page_cls,
+ sr=sr,
+ pr=pr,
+ request_body=request_body,
+ phase=phase,
+ previous_progress=self.snapshot,
+ credits_used=credits_used,
+ workflow_run_time_seconds=workflow_run_time_seconds,
+ workflow_credits=workflow_credits,
+ error_msg=error_msg,
+ current_workflow_completed=current_workflow_completed,
+ )
+ response.bulk_progress = self.snapshot
+
+ return (
+ f"{bulk_progress_percent(self.snapshot)}% Completed",
+ {"bulk_progress": self.snapshot},
+ )
+
+
+def is_bulk_progress_complete(progress: BulkProgressCounts) -> bool:
+ total_unit_runs = progress["total_unit_runs"]
+ return total_unit_runs > 0 and progress["completed_unit_runs"] >= total_unit_runs
+
+
+def build_bulk_progress(
+ *,
+ progress: BulkProgressCounts,
+ page_cls: type[BasePage],
+ sr: SavedRun,
+ pr,
+ request_body: dict,
+ phase: BulkProgressPhase,
+ previous_progress: BulkProgress | None = None,
+ credits_used: int | None = None,
+ workflow_run_time_seconds: float | None = None,
+ workflow_credits: int | None = None,
+ error_msg: str | None = None,
+ current_workflow_completed: bool = True,
+) -> BulkProgress:
+ title = get_title_breadcrumbs(page_cls=page_cls, sr=sr, pr=pr).title_with_prefix()
+ input_prompt = page_cls.preview_input(request_body)
+ bulk_progress: BulkProgress = {
+ **progress,
+ "phase": phase,
+ "workflow_title": title,
+ "workflow_url": sr.get_app_url(),
+ "input_prompt": build_input_prompt_preview(input_prompt),
+ }
+ if input_audio := request_body.get("input_audio"):
+ bulk_progress["input_audio"] = input_audio
+ if credits_used is not None:
+ bulk_progress["credits_used"] = credits_used
+ elif previous_progress and "credits_used" in previous_progress:
+ bulk_progress["credits_used"] = previous_progress["credits_used"]
+ if workflow_run_time_seconds is not None:
+ bulk_progress["workflow_run_time_seconds"] = workflow_run_time_seconds
+ if workflow_credits is not None:
+ bulk_progress["workflow_credits"] = workflow_credits
+ if error_msg:
+ bulk_progress["error_msg"] = error_msg
+
+ if current_workflow_completed:
+ bulk_progress["last_completed_workflow_title"] = title
+ bulk_progress["last_completed_workflow_url"] = sr.get_app_url()
+ if workflow_run_time_seconds is not None:
+ bulk_progress["last_completed_run_time_seconds"] = workflow_run_time_seconds
+ if workflow_credits is not None:
+ bulk_progress["last_completed_credits"] = workflow_credits
+ elif previous_progress:
+ for key in (
+ "last_completed_workflow_title",
+ "last_completed_workflow_url",
+ "last_completed_run_time_seconds",
+ "last_completed_credits",
+ ):
+ if key in previous_progress:
+ bulk_progress[key] = previous_progress[key]
+
+ return bulk_progress
+
+
+def build_input_prompt_preview(input_prompt: str | None) -> str:
+ if not input_prompt:
+ return ""
+
+ if len(input_prompt) <= BULK_INPUT_PROMPT_PREVIEW_CHARS:
+ return input_prompt
+
+ return input_prompt[: BULK_INPUT_PROMPT_PREVIEW_CHARS - 3].rstrip() + "..."
+
+
+def bulk_progress_percent(progress: BulkProgressCounts | None) -> int:
+ if not progress or not progress["total_unit_runs"]:
+ return 0
+ return round(progress["completed_unit_runs"] / progress["total_unit_runs"] * 100)