diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx
index 726713a..51915c4 100644
--- a/src/workflow/WorkflowPage.tsx
+++ b/src/workflow/WorkflowPage.tsx
@@ -49,6 +49,12 @@ import { persistentStorage } from "@/lib/storage";
import type { Template } from "@/types/template";
import type { NodeTypeDefinition } from "@/workflow/types/node-defs";
import { getOutputItemType } from "./lib/outputDisplay";
+import {
+ aggregateWorkflowCostPreviews,
+ formatWorkflowCost,
+ getWorkflowNodeCostPreview,
+ hasWorkflowCostDiscount,
+} from "./lib/cost-preview";
type ModelSyncStatus =
| "idle"
@@ -1156,6 +1162,28 @@ export function WorkflowPage() {
const modelsError = useModelsStore((s) => s.error);
const fetchModels = useModelsStore((s) => s.fetchModels);
+ const estimatedWorkflowCost = useMemo(() => {
+ const modelById = new Map(
+ desktopModels.map((model) => [model.model_id, model]),
+ );
+ const baseEstimate = aggregateWorkflowCostPreviews(
+ nodes.map((node) =>
+ getWorkflowNodeCostPreview({
+ nodeType: node.data?.nodeType,
+ params: node.data?.params,
+ model: modelById.get(String(node.data?.params?.modelId ?? "")),
+ }),
+ ),
+ );
+ if (!baseEstimate) return null;
+ const multiplier = Math.max(1, Math.floor(Number(runCount) || 1));
+ if (multiplier === 1) return baseEstimate;
+ return {
+ price: baseEstimate.price * multiplier,
+ discountedPrice: baseEstimate.discountedPrice * multiplier,
+ };
+ }, [desktopModels, nodes, runCount]);
+
const syncModels = useCallback(async () => {
if (!apiKey) {
setModelSyncStatus("no-key");
@@ -1811,6 +1839,43 @@ export function WorkflowPage() {
{/* Right: Run controls */}
+ {estimatedWorkflowCost && (
+
+
+
+ {t("workflow.totalEstimate", "Total Est.")}
+ {hasWorkflowCostDiscount(estimatedWorkflowCost) ? (
+
+
+ ${formatWorkflowCost(estimatedWorkflowCost.price)}
+
+
+ $
+ {formatWorkflowCost(
+ estimatedWorkflowCost.discountedPrice,
+ )}
+
+
+ ) : (
+
+ ${formatWorkflowCost(estimatedWorkflowCost.price)}
+
+ )}
+ {runCount > 1 && (
+
+ ×{runCount}
+
+ )}
+
+
+
+ {t(
+ "workflow.costEstimateHint",
+ "Estimated base price before running. Actual API cost may vary with inputs.",
+ )}
+
+
+ )}
{/* Run button — disabled in browser (no execution API) */}
diff --git a/src/workflow/components/canvas/WorkflowCanvas.tsx b/src/workflow/components/canvas/WorkflowCanvas.tsx
index af0a410..fd00064 100644
--- a/src/workflow/components/canvas/WorkflowCanvas.tsx
+++ b/src/workflow/components/canvas/WorkflowCanvas.tsx
@@ -2635,14 +2635,14 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) {
deleteKeyCode={null}
minZoom={0.05}
maxZoom={2.5}
- className="bg-background"
+ className="bg-slate-100 dark:bg-[#07111f]"
>
{showGrid && (
)}
diff --git a/src/workflow/components/canvas/custom-node/CustomNode.tsx b/src/workflow/components/canvas/custom-node/CustomNode.tsx
index ab5e75d..ef6dfcf 100644
--- a/src/workflow/components/canvas/custom-node/CustomNode.tsx
+++ b/src/workflow/components/canvas/custom-node/CustomNode.tsx
@@ -30,6 +30,11 @@ import { workflowClient } from "@/api/client";
import { useModelsStore } from "@/stores/modelsStore";
import { getFormFieldsFromModel } from "@/lib/schemaToForm";
import { formFieldsToModelParamSchema } from "../../../lib/model-converter";
+import {
+ formatWorkflowCost,
+ getWorkflowNodeCostPreview,
+ hasWorkflowCostDiscount,
+} from "../../../lib/cost-preview";
import type { NodeStatus } from "@/workflow/types/execution";
import type { WaveSpeedModel } from "@/workflow/types/node-defs";
import type { FormFieldConfig } from "@/lib/schemaToForm";
@@ -242,6 +247,15 @@ function CustomNodeComponent({
const isAITask = data.nodeType === "ai-task/run";
const currentModelId = String(data.params?.modelId ?? "").trim();
const currentModel = useModelsStore((s) => s.getModelById(currentModelId));
+ const costPreview = useMemo(
+ () =>
+ getWorkflowNodeCostPreview({
+ nodeType: data.nodeType,
+ params: data.params,
+ model: currentModel,
+ }),
+ [data.nodeType, data.params, currentModel],
+ );
const schema = useMemo(() => {
if (isAITask && currentModel) {
@@ -850,7 +864,8 @@ function CustomNodeComponent({
ref={nodeRef}
className={`
relative rounded-xl
- bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))]
+ bg-white text-[hsl(var(--card-foreground))]
+ dark:bg-slate-800
border-2
${resizing ? "" : "transition-all duration-300"}
${running ? (isInsideIterator ? "border-blue-500 animate-pulse-subtle" : "border-blue-500 animate-pulse-subtle") : ""}
@@ -858,7 +873,7 @@ function CustomNodeComponent({
${!running && !selected && status === "confirmed" ? "border-green-500/70" : ""}
${!running && !selected && status === "unconfirmed" ? "border-orange-500/70" : ""}
${!running && !selected && status === "error" ? "border-red-500/70" : ""}
- ${!running && !selected && status === "idle" ? (hovered ? "border-[hsl(var(--border))] shadow-lg" : "border-[hsl(var(--border))] shadow-md") : ""}
+ ${!running && !selected && status === "idle" ? (hovered ? "border-slate-300 shadow-lg dark:border-slate-500 dark:shadow-[0_0_0_1px_rgba(148,163,184,.16),0_16px_36px_rgba(0,0,0,.45)]" : "border-slate-200 shadow-md dark:border-slate-600/80 dark:shadow-[0_0_0_1px_rgba(148,163,184,.10),0_12px_28px_rgba(0,0,0,.38)]") : ""}
${isInsideIterator && !running && !selected && status === "idle" ? "ring-1 ring-blue-500/20" : ""}
`}
style={{ width: savedWidth, minHeight: savedHeight, fontSize: 13 }}
@@ -905,12 +920,42 @@ function CustomNodeComponent({
)}
-
+
{nodeLabel}
{shortId}
+ {costPreview && (
+
+
+
+ {t("workflow.estimated", "Est.")}
+ {hasWorkflowCostDiscount(costPreview) ? (
+
+
+ ${formatWorkflowCost(costPreview.price)}
+
+
+ ${formatWorkflowCost(costPreview.discountedPrice)}
+
+
+ ) : (
+ ${formatWorkflowCost(costPreview.price)}
+ )}
+
+
+
+ {t(
+ "workflow.costEstimateHint",
+ "Estimated base price before running. Actual API cost may vary with inputs.",
+ )}
+ {costPreview.runCount > 1
+ ? ` ${t("workflow.runCount", "Run Count")}: ${costPreview.runCount}`
+ : ""}
+
+
+ )}
{/* ── Running status bar ── */}
{running && (
diff --git a/src/workflow/lib/cost-preview.ts b/src/workflow/lib/cost-preview.ts
new file mode 100644
index 0000000..11f21d3
--- /dev/null
+++ b/src/workflow/lib/cost-preview.ts
@@ -0,0 +1,63 @@
+import { applyDiscount, getModelDiscountRate } from "@/lib/pricing";
+import type { PriceDisplay } from "@/lib/pricing";
+import type { Model } from "@/types/model";
+
+export interface WorkflowCostPreview extends PriceDisplay {
+ runCount: number;
+}
+
+function readRunCount(params?: Record): number {
+ const raw = Number(params?.__runCount ?? 1);
+ if (!Number.isFinite(raw)) return 1;
+ return Math.max(1, Math.floor(raw));
+}
+
+export function getWorkflowNodeCostPreview({
+ nodeType,
+ params,
+ model,
+}: {
+ nodeType?: string;
+ params?: Record;
+ model?: Model;
+}): WorkflowCostPreview | null {
+ if (nodeType !== "ai-task/run" || !model) return null;
+
+ const basePrice = Number(model.base_price ?? 0);
+ if (!Number.isFinite(basePrice) || basePrice <= 0) return null;
+
+ const runCount = readRunCount(params);
+ const display = applyDiscount(
+ basePrice * runCount,
+ getModelDiscountRate(model),
+ );
+ return { ...display, runCount };
+}
+
+export function aggregateWorkflowCostPreviews(
+ previews: Array,
+): PriceDisplay | null {
+ const valid = previews.filter(
+ (preview): preview is WorkflowCostPreview => !!preview,
+ );
+ if (valid.length === 0) return null;
+ return valid.reduce(
+ (sum, preview) => ({
+ price: sum.price + preview.price,
+ discountedPrice: sum.discountedPrice + preview.discountedPrice,
+ }),
+ { price: 0, discountedPrice: 0 },
+ );
+}
+
+export function hasWorkflowCostDiscount(price: PriceDisplay): boolean {
+ return price.discountedPrice > 0 && price.discountedPrice < price.price;
+}
+
+export function formatWorkflowCost(value: number): string {
+ if (!Number.isFinite(value)) return "0.0000";
+ const normalized = Math.max(0, value);
+ if (normalized >= 1) return normalized.toFixed(2);
+ if (normalized >= 0.01) return normalized.toFixed(3);
+ return normalized.toFixed(4);
+}