From e7a7719346d92ef18e5589c09523b57a520326e5 Mon Sep 17 00:00:00 2001 From: Haorui Jiang <143785706+HiramJiang@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:40:23 +0800 Subject: [PATCH] Add workflow cost preview and dark canvas contrast --- src/workflow/WorkflowPage.tsx | 65 +++++++++++++++++++ .../components/canvas/WorkflowCanvas.tsx | 4 +- .../canvas/custom-node/CustomNode.tsx | 51 ++++++++++++++- src/workflow/lib/cost-preview.ts | 63 ++++++++++++++++++ 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 src/workflow/lib/cost-preview.ts 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); +}