Skip to content

Commit fd5c13f

Browse files
authored
Merge pull request #107 from WaveSpeedAI/codex/workflow-cost-preview-contrast
Add workflow cost preview and dark canvas contrast
2 parents 0fefa57 + e7a7719 commit fd5c13f

4 files changed

Lines changed: 178 additions & 5 deletions

File tree

src/workflow/WorkflowPage.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ import { persistentStorage } from "@/lib/storage";
4949
import type { Template } from "@/types/template";
5050
import type { NodeTypeDefinition } from "@/workflow/types/node-defs";
5151
import { getOutputItemType } from "./lib/outputDisplay";
52+
import {
53+
aggregateWorkflowCostPreviews,
54+
formatWorkflowCost,
55+
getWorkflowNodeCostPreview,
56+
hasWorkflowCostDiscount,
57+
} from "./lib/cost-preview";
5258

5359
type ModelSyncStatus =
5460
| "idle"
@@ -1156,6 +1162,28 @@ export function WorkflowPage() {
11561162
const modelsError = useModelsStore((s) => s.error);
11571163
const fetchModels = useModelsStore((s) => s.fetchModels);
11581164

1165+
const estimatedWorkflowCost = useMemo(() => {
1166+
const modelById = new Map(
1167+
desktopModels.map((model) => [model.model_id, model]),
1168+
);
1169+
const baseEstimate = aggregateWorkflowCostPreviews(
1170+
nodes.map((node) =>
1171+
getWorkflowNodeCostPreview({
1172+
nodeType: node.data?.nodeType,
1173+
params: node.data?.params,
1174+
model: modelById.get(String(node.data?.params?.modelId ?? "")),
1175+
}),
1176+
),
1177+
);
1178+
if (!baseEstimate) return null;
1179+
const multiplier = Math.max(1, Math.floor(Number(runCount) || 1));
1180+
if (multiplier === 1) return baseEstimate;
1181+
return {
1182+
price: baseEstimate.price * multiplier,
1183+
discountedPrice: baseEstimate.discountedPrice * multiplier,
1184+
};
1185+
}, [desktopModels, nodes, runCount]);
1186+
11591187
const syncModels = useCallback(async () => {
11601188
if (!apiKey) {
11611189
setModelSyncStatus("no-key");
@@ -1811,6 +1839,43 @@ export function WorkflowPage() {
18111839

18121840
{/* Right: Run controls */}
18131841
<div className="flex items-center gap-1.5" data-guide="run-controls">
1842+
{estimatedWorkflowCost && (
1843+
<Tooltip delayDuration={0}>
1844+
<TooltipTrigger asChild>
1845+
<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">
1846+
<span>{t("workflow.totalEstimate", "Total Est.")}</span>
1847+
{hasWorkflowCostDiscount(estimatedWorkflowCost) ? (
1848+
<span className="inline-flex items-baseline gap-1">
1849+
<span className="line-through opacity-60">
1850+
${formatWorkflowCost(estimatedWorkflowCost.price)}
1851+
</span>
1852+
<span>
1853+
$
1854+
{formatWorkflowCost(
1855+
estimatedWorkflowCost.discountedPrice,
1856+
)}
1857+
</span>
1858+
</span>
1859+
) : (
1860+
<span>
1861+
${formatWorkflowCost(estimatedWorkflowCost.price)}
1862+
</span>
1863+
)}
1864+
{runCount > 1 && (
1865+
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-1 text-[10px]">
1866+
×{runCount}
1867+
</span>
1868+
)}
1869+
</span>
1870+
</TooltipTrigger>
1871+
<TooltipContent side="bottom" className="max-w-[260px]">
1872+
{t(
1873+
"workflow.costEstimateHint",
1874+
"Estimated base price before running. Actual API cost may vary with inputs.",
1875+
)}
1876+
</TooltipContent>
1877+
</Tooltip>
1878+
)}
18141879
<div className="flex items-center rounded-lg overflow-hidden shadow-sm">
18151880
{/* Run button — disabled in browser (no execution API) */}
18161881
<Tooltip delayDuration={0}>

src/workflow/components/canvas/WorkflowCanvas.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2635,14 +2635,14 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) {
26352635
deleteKeyCode={null}
26362636
minZoom={0.05}
26372637
maxZoom={2.5}
2638-
className="bg-background"
2638+
className="bg-slate-100 dark:bg-[#07111f]"
26392639
>
26402640
{showGrid && (
26412641
<Background
26422642
variant={BackgroundVariant.Lines}
26432643
gap={20}
26442644
lineWidth={1}
2645-
color="hsl(var(--border))"
2645+
color="hsl(var(--muted-foreground) / 0.2)"
26462646
/>
26472647
)}
26482648
</ReactFlow>

src/workflow/components/canvas/custom-node/CustomNode.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import { workflowClient } from "@/api/client";
3030
import { useModelsStore } from "@/stores/modelsStore";
3131
import { getFormFieldsFromModel } from "@/lib/schemaToForm";
3232
import { formFieldsToModelParamSchema } from "../../../lib/model-converter";
33+
import {
34+
formatWorkflowCost,
35+
getWorkflowNodeCostPreview,
36+
hasWorkflowCostDiscount,
37+
} from "../../../lib/cost-preview";
3338
import type { NodeStatus } from "@/workflow/types/execution";
3439
import type { WaveSpeedModel } from "@/workflow/types/node-defs";
3540
import type { FormFieldConfig } from "@/lib/schemaToForm";
@@ -242,6 +247,15 @@ function CustomNodeComponent({
242247
const isAITask = data.nodeType === "ai-task/run";
243248
const currentModelId = String(data.params?.modelId ?? "").trim();
244249
const currentModel = useModelsStore((s) => s.getModelById(currentModelId));
250+
const costPreview = useMemo(
251+
() =>
252+
getWorkflowNodeCostPreview({
253+
nodeType: data.nodeType,
254+
params: data.params,
255+
model: currentModel,
256+
}),
257+
[data.nodeType, data.params, currentModel],
258+
);
245259

246260
const schema = useMemo(() => {
247261
if (isAITask && currentModel) {
@@ -850,15 +864,16 @@ function CustomNodeComponent({
850864
ref={nodeRef}
851865
className={`
852866
relative rounded-xl
853-
bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))]
867+
bg-white text-[hsl(var(--card-foreground))]
868+
dark:bg-slate-800
854869
border-2
855870
${resizing ? "" : "transition-all duration-300"}
856871
${running ? (isInsideIterator ? "border-blue-500 animate-pulse-subtle" : "border-blue-500 animate-pulse-subtle") : ""}
857872
${!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") : ""}
858873
${!running && !selected && status === "confirmed" ? "border-green-500/70" : ""}
859874
${!running && !selected && status === "unconfirmed" ? "border-orange-500/70" : ""}
860875
${!running && !selected && status === "error" ? "border-red-500/70" : ""}
861-
${!running && !selected && status === "idle" ? (hovered ? "border-[hsl(var(--border))] shadow-lg" : "border-[hsl(var(--border))] shadow-md") : ""}
876+
${!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)]") : ""}
862877
${isInsideIterator && !running && !selected && status === "idle" ? "ring-1 ring-blue-500/20" : ""}
863878
`}
864879
style={{ width: savedWidth, minHeight: savedHeight, fontSize: 13 }}
@@ -905,12 +920,42 @@ function CustomNodeComponent({
905920
<NodeIcon className="w-3.5 h-3.5 text-primary" />
906921
</div>
907922
)}
908-
<span className="font-semibold text-[13px] truncate">
923+
<span className="font-semibold text-[13px] truncate min-w-0 flex-1">
909924
{nodeLabel}
910925
</span>
911926
<span className="text-[10px] text-[hsl(var(--muted-foreground))] opacity-50 font-mono flex-shrink-0">
912927
{shortId}
913928
</span>
929+
{costPreview && (
930+
<Tooltip delayDuration={0}>
931+
<TooltipTrigger asChild>
932+
<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">
933+
<span>{t("workflow.estimated", "Est.")}</span>
934+
{hasWorkflowCostDiscount(costPreview) ? (
935+
<span className="inline-flex items-baseline gap-1">
936+
<span className="line-through opacity-60">
937+
${formatWorkflowCost(costPreview.price)}
938+
</span>
939+
<span>
940+
${formatWorkflowCost(costPreview.discountedPrice)}
941+
</span>
942+
</span>
943+
) : (
944+
<span>${formatWorkflowCost(costPreview.price)}</span>
945+
)}
946+
</span>
947+
</TooltipTrigger>
948+
<TooltipContent side="top" className="max-w-[240px]">
949+
{t(
950+
"workflow.costEstimateHint",
951+
"Estimated base price before running. Actual API cost may vary with inputs.",
952+
)}
953+
{costPreview.runCount > 1
954+
? ` ${t("workflow.runCount", "Run Count")}: ${costPreview.runCount}`
955+
: ""}
956+
</TooltipContent>
957+
</Tooltip>
958+
)}
914959
</div>
915960
{/* ── Running status bar ── */}
916961
{running && (

src/workflow/lib/cost-preview.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { applyDiscount, getModelDiscountRate } from "@/lib/pricing";
2+
import type { PriceDisplay } from "@/lib/pricing";
3+
import type { Model } from "@/types/model";
4+
5+
export interface WorkflowCostPreview extends PriceDisplay {
6+
runCount: number;
7+
}
8+
9+
function readRunCount(params?: Record<string, unknown>): number {
10+
const raw = Number(params?.__runCount ?? 1);
11+
if (!Number.isFinite(raw)) return 1;
12+
return Math.max(1, Math.floor(raw));
13+
}
14+
15+
export function getWorkflowNodeCostPreview({
16+
nodeType,
17+
params,
18+
model,
19+
}: {
20+
nodeType?: string;
21+
params?: Record<string, unknown>;
22+
model?: Model;
23+
}): WorkflowCostPreview | null {
24+
if (nodeType !== "ai-task/run" || !model) return null;
25+
26+
const basePrice = Number(model.base_price ?? 0);
27+
if (!Number.isFinite(basePrice) || basePrice <= 0) return null;
28+
29+
const runCount = readRunCount(params);
30+
const display = applyDiscount(
31+
basePrice * runCount,
32+
getModelDiscountRate(model),
33+
);
34+
return { ...display, runCount };
35+
}
36+
37+
export function aggregateWorkflowCostPreviews(
38+
previews: Array<WorkflowCostPreview | null | undefined>,
39+
): PriceDisplay | null {
40+
const valid = previews.filter(
41+
(preview): preview is WorkflowCostPreview => !!preview,
42+
);
43+
if (valid.length === 0) return null;
44+
return valid.reduce<PriceDisplay>(
45+
(sum, preview) => ({
46+
price: sum.price + preview.price,
47+
discountedPrice: sum.discountedPrice + preview.discountedPrice,
48+
}),
49+
{ price: 0, discountedPrice: 0 },
50+
);
51+
}
52+
53+
export function hasWorkflowCostDiscount(price: PriceDisplay): boolean {
54+
return price.discountedPrice > 0 && price.discountedPrice < price.price;
55+
}
56+
57+
export function formatWorkflowCost(value: number): string {
58+
if (!Number.isFinite(value)) return "0.0000";
59+
const normalized = Math.max(0, value);
60+
if (normalized >= 1) return normalized.toFixed(2);
61+
if (normalized >= 0.01) return normalized.toFixed(3);
62+
return normalized.toFixed(4);
63+
}

0 commit comments

Comments
 (0)