Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/workflow/WorkflowPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -1811,6 +1839,43 @@ export function WorkflowPage() {

{/* Right: Run controls */}
<div className="flex items-center gap-1.5" data-guide="run-controls">
{estimatedWorkflowCost && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="h-7 inline-flex items-center gap-1.5 rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-2 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 whitespace-nowrap">
<span>{t("workflow.totalEstimate", "Total Est.")}</span>
{hasWorkflowCostDiscount(estimatedWorkflowCost) ? (
<span className="inline-flex items-baseline gap-1">
<span className="line-through opacity-60">
${formatWorkflowCost(estimatedWorkflowCost.price)}
</span>
<span>
$
{formatWorkflowCost(
estimatedWorkflowCost.discountedPrice,
)}
</span>
</span>
) : (
<span>
${formatWorkflowCost(estimatedWorkflowCost.price)}
</span>
)}
{runCount > 1 && (
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-1 text-[10px]">
×{runCount}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px]">
{t(
"workflow.costEstimateHint",
"Estimated base price before running. Actual API cost may vary with inputs.",
)}
</TooltipContent>
</Tooltip>
)}
<div className="flex items-center rounded-lg overflow-hidden shadow-sm">
{/* Run button — disabled in browser (no execution API) */}
<Tooltip delayDuration={0}>
Expand Down
4 changes: 2 additions & 2 deletions src/workflow/components/canvas/WorkflowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<Background
variant={BackgroundVariant.Lines}
gap={20}
lineWidth={1}
color="hsl(var(--border))"
color="hsl(var(--muted-foreground) / 0.2)"
/>
)}
</ReactFlow>
Expand Down
51 changes: 48 additions & 3 deletions src/workflow/components/canvas/custom-node/CustomNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -850,15 +864,16 @@ 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") : ""}
${!running && selected ? (isInsideIterator ? "border-blue-500 shadow-[0_0_20px_rgba(96,165,250,.25)] ring-1 ring-blue-500/30" : "border-blue-500 shadow-[0_0_20px_rgba(96,165,250,.25)] ring-1 ring-blue-500/30") : ""}
${!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 }}
Expand Down Expand Up @@ -905,12 +920,42 @@ function CustomNodeComponent({
<NodeIcon className="w-3.5 h-3.5 text-primary" />
</div>
)}
<span className="font-semibold text-[13px] truncate">
<span className="font-semibold text-[13px] truncate min-w-0 flex-1">
{nodeLabel}
</span>
<span className="text-[10px] text-[hsl(var(--muted-foreground))] opacity-50 font-mono flex-shrink-0">
{shortId}
</span>
{costPreview && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="nodrag nopan flex-shrink-0 inline-flex items-center gap-1 rounded-full border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-300">
<span>{t("workflow.estimated", "Est.")}</span>
{hasWorkflowCostDiscount(costPreview) ? (
<span className="inline-flex items-baseline gap-1">
<span className="line-through opacity-60">
${formatWorkflowCost(costPreview.price)}
</span>
<span>
${formatWorkflowCost(costPreview.discountedPrice)}
</span>
</span>
) : (
<span>${formatWorkflowCost(costPreview.price)}</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{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}`
: ""}
</TooltipContent>
</Tooltip>
)}
</div>
{/* ── Running status bar ── */}
{running && (
Expand Down
63 changes: 63 additions & 0 deletions src/workflow/lib/cost-preview.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<string, unknown>;
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<WorkflowCostPreview | null | undefined>,
): PriceDisplay | null {
const valid = previews.filter(
(preview): preview is WorkflowCostPreview => !!preview,
);
if (valid.length === 0) return null;
return valid.reduce<PriceDisplay>(
(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);
}
Loading