inline dialogs and react flow ui refactor WIP p1

This commit is contained in:
shine1i 2026-02-06 00:42:35 +01:00
commit b15f4e7ad5
15 changed files with 1027 additions and 78 deletions

View file

@ -18,8 +18,9 @@ import { EyeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { previewCanvas } from "./api";
import { BlockSheet } from "./components/block-sheet";
import { CanvasEdge } from "./components/canvas-edge";
import { CanvasNode } from "./components/canvas-node";
import { CanvasSemanticEdge } from "./components/canvas-semantic-edge";
import { DataEdge } from "./components/rf-ui/data-edge";
import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { ProcessorsDialog } from "./dialogs/processors-dialog";
@ -31,7 +32,7 @@ import { buildCanvasPayload } from "./utils/payload";
import { buildDefaultSchemaTransform } from "./utils/processors";
const NODE_TYPES: NodeTypes = { builder: CanvasNode };
const EDGE_TYPES: EdgeTypes = { canvas: CanvasEdge, semantic: CanvasEdge };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: CanvasSemanticEdge };
type LayoutControlsProps = {
direction: "LR" | "TB";
@ -83,7 +84,7 @@ export function CanvasLabPage(): ReactElement {
addModelProviderNode,
addModelConfigNode,
addExpressionNode,
openConfig,
selectConfig,
updateConfig,
isValidConnection,
setSheetView,
@ -110,7 +111,7 @@ export function CanvasLabPage(): ReactElement {
addModelProviderNode: state.addModelProviderNode,
addModelConfigNode: state.addModelConfigNode,
addExpressionNode: state.addExpressionNode,
openConfig: state.openConfig,
selectConfig: state.selectConfig,
updateConfig: state.updateConfig,
isValidConnection: state.isValidConnection,
setSheetView: state.setSheetView,
@ -134,9 +135,9 @@ export function CanvasLabPage(): ReactElement {
const handleNodeClick = useCallback(
(_: unknown, node: Node<CanvasNodeData>) => {
openConfig(node.id);
selectConfig(node.id);
},
[openConfig],
[selectConfig],
);
const config = activeConfigId ? configs[activeConfigId] : null;
@ -310,7 +311,8 @@ export function CanvasLabPage(): ReactElement {
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
style: { strokeWidth: 1.5, stroke: "#cbd5f5" },
data: { key: "name", path: "smoothstep" },
style: { strokeWidth: 1.5, stroke: "var(--border)" },
}}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}

View file

@ -1,3 +1,4 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
BalanceScaleIcon,
@ -17,15 +18,28 @@ import {
UserAccountIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { NodeProps } from "@xyflow/react";
import { Handle, Position, useUpdateNodeInternals } from "@xyflow/react";
import { type ReactElement, memo, useEffect } from "react";
import { Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react";
import { memo, type ReactElement, useEffect } from "react";
import { useCanvasLabStore } from "../stores/canvas-lab";
import type {
CanvasNode as CanvasNodeType,
LlmType,
NodeConfig,
SamplerType,
} from "../types";
import { HANDLE_IDS } from "../utils/handles";
import { InlineExpression } from "./inline/inline-expression";
import { InlineLlm } from "./inline/inline-llm";
import { InlineModel } from "./inline/inline-model";
import { isInlineConfig } from "./inline/inline-policy";
import { InlineSampler } from "./inline/inline-sampler";
import {
BaseNode,
BaseNodeContent,
BaseNodeHeader,
BaseNodeHeaderTitle,
} from "./rf-ui/base-node";
import { LabeledHandle } from "./rf-ui/labeled-handle";
type IconType = typeof CodeIcon;
@ -34,16 +48,16 @@ const NODE_META = {
tone: "bg-emerald-50 text-emerald-600 border-emerald-100",
},
llm: {
tone: "bg-purple-50 text-purple-600 border-purple-100",
tone: "bg-sky-50 text-sky-600 border-sky-100",
},
expression: {
tone: "bg-sky-50 text-sky-600 border-sky-100",
tone: "bg-indigo-50 text-indigo-600 border-indigo-100",
},
model_provider: {
tone: "bg-amber-50 text-amber-600 border-amber-100",
},
model_config: {
tone: "bg-indigo-50 text-indigo-600 border-indigo-100",
tone: "bg-orange-50 text-orange-600 border-orange-100",
},
} as const;
@ -89,92 +103,244 @@ function resolveNodeIcon(
return DiceFaces03Icon;
}
function CanvasNodeBase({
id,
data,
selected,
}: NodeProps<CanvasNodeType>): ReactElement {
function toSingleLine(value: string | undefined): string {
if (!value) {
return "";
}
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
if (normalized.length <= 96) {
return normalized;
}
return `${normalized.slice(0, 93)}...`;
}
function getConfigSummary(config: NodeConfig | undefined): string {
if (!config) {
return "Open details for config";
}
if (config.kind === "sampler") {
if (config.sampler_type === "category") {
const count = config.values?.length ?? 0;
return `${count} values`;
}
if (config.sampler_type === "subcategory") {
if (config.subcategory_parent?.trim()) {
return `Parent: ${config.subcategory_parent}`;
}
return "Select parent category";
}
if (config.sampler_type === "datetime") {
const start = config.datetime_start?.trim() || "?";
const end = config.datetime_end?.trim() || "?";
return `${start} -> ${end}`;
}
if (config.sampler_type === "timedelta") {
if (config.reference_column_name?.trim()) {
return `Ref: ${config.reference_column_name}`;
}
return "Pick datetime reference";
}
if (
config.sampler_type === "person" ||
config.sampler_type === "person_from_faker"
) {
const locale = config.person_locale?.trim() || "any locale";
const city = config.person_city?.trim();
if (city) {
return `${locale} · ${city}`;
}
return locale;
}
return "Open details for config";
}
if (config.kind === "llm") {
const prompt = toSingleLine(config.prompt);
if (config.llm_type === "structured") {
if (prompt) {
return `Prompt: ${prompt}`;
}
return "Structured output schema in details";
}
if (config.llm_type === "judge") {
const scoreCount = config.scores?.length ?? 0;
if (prompt) {
return `${scoreCount} scores · ${prompt}`;
}
return `${scoreCount} scores`;
}
return "Open details for config";
}
return "Open details for config";
}
function renderInlineEditor(
config: NodeConfig | undefined,
updateConfig: (id: string, patch: Partial<NodeConfig>) => void,
): ReactElement | null {
if (!config || !isInlineConfig(config)) {
return null;
}
if (config.kind === "sampler") {
return (
<InlineSampler
config={config}
onUpdate={(patch) => updateConfig(config.id, patch)}
/>
);
}
if (config.kind === "model_provider" || config.kind === "model_config") {
return (
<InlineModel
config={config}
onUpdate={(patch) => updateConfig(config.id, patch)}
/>
);
}
if (config.kind === "llm") {
return (
<InlineLlm config={config} onUpdate={(patch) => updateConfig(config.id, patch)} />
);
}
if (config.kind === "expression") {
return (
<InlineExpression
config={config}
onUpdate={(patch) => updateConfig(config.id, patch)}
/>
);
}
return null;
}
function CanvasNodeBase({ id, data }: NodeProps<CanvasNodeType>): ReactElement {
const meta = NODE_META[data.kind];
const icon = resolveNodeIcon(data.kind, data.blockType);
const layoutDirection = data.layoutDirection ?? "LR";
const config = useCanvasLabStore((state) => state.configs[id]);
const openConfig = useCanvasLabStore((state) => state.openConfig);
const updateConfig = useCanvasLabStore((state) => state.updateConfig);
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
updateNodeInternals(id);
}, [id, layoutDirection, updateNodeInternals]);
}, [id, layoutDirection, config, updateNodeInternals]);
const showDataHandles =
data.kind === "llm" ||
data.kind === "expression" ||
(data.kind === "sampler" &&
data.blockType !== "model_provider" &&
data.blockType !== "model_config");
const showSemanticIn =
data.kind === "llm" ||
data.kind === "model_config";
const showSemanticOut =
data.kind === "llm" ||
data.kind === "model_config" ||
data.kind === "model_provider";
data.kind === "llm" || data.kind === "expression" || data.kind === "sampler";
const showSemanticIn = data.kind === "llm" || data.kind === "model_config";
const showSemanticOut = data.kind === "model_config" || data.kind === "model_provider";
const isTopBottom = layoutDirection === "TB";
const dataInPosition = isTopBottom ? Position.Top : Position.Left;
const dataOutPosition = isTopBottom ? Position.Bottom : Position.Right;
const semanticInPosition = isTopBottom ? Position.Left : Position.Top;
const semanticOutPosition = isTopBottom ? Position.Right : Position.Bottom;
const inlineEditor = renderInlineEditor(config, updateConfig);
const summary = getConfigSummary(config);
return (
<div
className={cn(
"rounded-2xl border bg-white px-4 py-3 shadow-sm min-w-[180px]",
selected
? "border-foreground/40 ring-1 ring-foreground/10"
: "border-border/60",
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex size-9 items-center justify-center rounded-xl border",
meta.tone,
)}
<BaseNode className="relative min-w-[260px] overflow-visible rounded-xl border-border/60 shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"flex size-7 items-center justify-center rounded-md border",
meta.tone,
)}
>
<HugeiconsIcon icon={icon} className="size-3.5" />
</div>
<div className="min-w-0">
<BaseNodeHeaderTitle className="truncate text-sm">
{data.title}
</BaseNodeHeaderTitle>
<p className="truncate text-[11px] text-muted-foreground">
{data.subtype} · {data.name}
</p>
</div>
</div>
<Button
type="button"
size="xs"
variant="ghost"
className="nodrag"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openConfig(id);
}}
>
<HugeiconsIcon icon={icon} className="size-4" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">{data.title}</p>
<p className="text-[11px] text-muted-foreground">
{data.subtype} · {data.name}
</p>
</div>
</div>
Details
</Button>
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
{inlineEditor ? (
inlineEditor
) : (
<p className="text-xs text-muted-foreground">{summary}</p>
)}
</BaseNodeContent>
{showDataHandles && (
<>
<Handle
<LabeledHandle
id={HANDLE_IDS.dataIn}
title="Data input"
type="target"
position={Position.Left}
className="size-2 border border-border bg-white"
position={dataInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
/>
<Handle
<LabeledHandle
id={HANDLE_IDS.dataOut}
title="Data output"
type="source"
position={Position.Right}
className="size-2 border border-border bg-white"
position={dataOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
/>
</>
)}
{showSemanticIn && (
<Handle
<LabeledHandle
id={HANDLE_IDS.semanticIn}
title="Semantic input"
type="target"
position={Position.Top}
className="size-2 border border-border bg-white"
position={semanticInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
/>
)}
{showSemanticOut && (
<Handle
<LabeledHandle
id={HANDLE_IDS.semanticOut}
title="Semantic output"
type="source"
position={Position.Bottom}
className="size-2 border border-border bg-white"
position={semanticOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
/>
)}
</div>
</BaseNode>
);
}

View file

@ -1,7 +1,7 @@
import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
import { memo, type ReactElement } from "react";
export const CanvasEdge = memo(function CanvasEdge({
export const CanvasSemanticEdge = memo(function CanvasSemanticEdge({
id,
sourceX,
sourceY,
@ -10,7 +10,7 @@ export const CanvasEdge = memo(function CanvasEdge({
sourcePosition,
targetPosition,
style,
type,
markerEnd,
}: EdgeProps): ReactElement {
const [path] = getSmoothStepPath({
sourceX,
@ -23,10 +23,17 @@ export const CanvasEdge = memo(function CanvasEdge({
offset: 16,
});
const nextStyle =
type === "semantic"
? { ...style, strokeDasharray: "4 4" }
: style;
return <BaseEdge id={id} path={path} style={nextStyle} />;
return (
<BaseEdge
id={id}
path={path}
markerEnd={markerEnd}
style={{
strokeDasharray: "4 4",
strokeWidth: 1.5,
stroke: "var(--muted-foreground)",
...style,
}}
/>
);
});

View file

@ -0,0 +1,50 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ReactElement } from "react";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
type InlineExpressionProps = {
config: ExpressionConfig;
onUpdate: (patch: Partial<ExpressionConfig>) => void;
};
const DTYPE_OPTIONS: ExpressionDtype[] = ["str", "int", "float", "bool"];
export function InlineExpression({
config,
onUpdate,
}: InlineExpressionProps): ReactElement {
return (
<div className="grid grid-cols-[110px_1fr] gap-2">
<Select
value={config.dtype}
onValueChange={(value) =>
onUpdate({ dtype: value as ExpressionDtype })
}
>
<SelectTrigger className="nodrag h-7 text-xs">
<SelectValue placeholder="dtype" />
</SelectTrigger>
<SelectContent>
{DTYPE_OPTIONS.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="nodrag h-7 text-xs"
placeholder="{{ column_name }}"
value={config.expr}
onChange={(event) => onUpdate({ expr: event.target.value })}
/>
</div>
);
}

View file

@ -0,0 +1,83 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { LlmConfig } from "../../types";
type InlineLlmProps = {
config: LlmConfig;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
const CODE_LANG_OPTIONS = [
"python",
"javascript",
"typescript",
"java",
"kotlin",
"go",
"rust",
"ruby",
"scala",
"swift",
"sql:sqlite",
"sql:postgres",
"sql:mysql",
"sql:tsql",
"sql:bigquery",
"sql:ansi",
] as const;
export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
const isCode = config.llm_type === "code";
return (
<div className="grid gap-2">
<Input
className="nodrag h-7 text-xs"
placeholder="Model alias"
value={config.model_alias}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: event.target.value,
})
}
/>
{isCode && (
<Select
value={config.code_lang?.trim() || "python"}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: value,
})
}
>
<SelectTrigger className="nodrag h-7 text-xs">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{CODE_LANG_OPTIONS.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Textarea
className="nodrag min-h-[56px] text-xs"
placeholder="Prompt"
value={config.prompt}
onChange={(event) => onUpdate({ prompt: event.target.value })}
/>
</div>
);
}

View file

@ -0,0 +1,65 @@
import { Input } from "@/components/ui/input";
import type { ReactElement } from "react";
import type { ModelConfig, ModelProviderConfig } from "../../types";
type InlineModelPatch = Partial<ModelProviderConfig> | Partial<ModelConfig>;
type InlineModelProps = {
config: ModelProviderConfig | ModelConfig;
onUpdate: (patch: InlineModelPatch) => void;
};
export function InlineModel(props: InlineModelProps): ReactElement {
if (props.config.kind === "model_provider") {
return (
<div className="grid grid-cols-2 gap-2">
<Input
className="nodrag h-7 text-xs"
placeholder="Provider type"
value={props.config.provider_type}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: event.target.value,
})
}
/>
<Input
className="nodrag h-7 text-xs"
placeholder="Endpoint"
value={props.config.endpoint}
onChange={(event) => props.onUpdate({ endpoint: event.target.value })}
/>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-2">
<Input
className="nodrag h-7 text-xs"
placeholder="Provider"
value={props.config.provider}
onChange={(event) => props.onUpdate({ provider: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
placeholder="Model"
value={props.config.model}
onChange={(event) => props.onUpdate({ model: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Temp"
value={props.config.inference_temperature ?? ""}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: event.target.value,
})
}
/>
</div>
);
}

View file

@ -0,0 +1,76 @@
import type { NodeConfig, SamplerType } from "../../types";
export type ConfigUiMode = "inline" | "dialog";
type InlineFieldMap = Record<string, readonly string[]>;
const INLINE_SAMPLERS = new Set<SamplerType>([
"uniform",
"gaussian",
"bernoulli",
"uuid",
]);
const INLINE_FIELD_MAP: InlineFieldMap = {
uniform: ["low", "high", "convert_to"],
gaussian: ["mean", "std", "convert_to"],
bernoulli: ["p"],
uuid: ["uuid_format"],
model_provider: ["provider_type", "endpoint"],
model_config: ["provider", "model", "inference_temperature"],
llm_text: ["model_alias", "prompt"],
llm_code: ["model_alias", "code_lang", "prompt"],
expression: ["dtype", "expr"],
};
export function getInlineFields(config: NodeConfig): readonly string[] {
if (config.kind === "sampler") {
return INLINE_FIELD_MAP[config.sampler_type] ?? [];
}
if (config.kind === "model_provider") {
return INLINE_FIELD_MAP.model_provider;
}
if (config.kind === "model_config") {
return INLINE_FIELD_MAP.model_config;
}
if (config.kind === "llm" && config.llm_type === "text") {
return INLINE_FIELD_MAP.llm_text;
}
if (config.kind === "llm" && config.llm_type === "code") {
return INLINE_FIELD_MAP.llm_code;
}
if (config.kind === "expression") {
return INLINE_FIELD_MAP.expression;
}
return [];
}
export function getConfigUiMode(
config: NodeConfig | null | undefined,
): ConfigUiMode {
if (!config) {
return "dialog";
}
if (config.kind === "sampler") {
return INLINE_SAMPLERS.has(config.sampler_type) ? "inline" : "dialog";
}
if (config.kind === "model_provider" || config.kind === "model_config") {
return "inline";
}
if (config.kind === "llm") {
if (config.llm_type === "text" || config.llm_type === "code") {
return "inline";
}
return "dialog";
}
if (config.kind === "expression") {
return "inline";
}
return "dialog";
}
export function isInlineConfig(
config: NodeConfig | null | undefined,
): boolean {
return getConfigUiMode(config) === "inline";
}

View file

@ -0,0 +1,142 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
type InlineSamplerProps = {
config: SamplerConfig;
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
type ConvertTo = "int" | "float" | "str";
function ConvertToField({
value,
onValueChange,
}: {
value: SamplerConfig["convert_to"];
onValueChange: (value: ConvertTo | undefined) => void;
}): ReactElement {
return (
<Select
value={value ?? "none"}
onValueChange={(next) =>
onValueChange(next === "none" ? undefined : (next as ConvertTo))
}
>
<SelectTrigger className="nodrag h-7 text-xs">
<SelectValue placeholder="Convert" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="int">int</SelectItem>
<SelectItem value="float">float</SelectItem>
<SelectItem value="str">str</SelectItem>
</SelectContent>
</Select>
);
}
export function InlineSampler({
config,
onUpdate,
}: InlineSamplerProps): ReactElement | null {
if (config.sampler_type === "uniform") {
return (
<div className="grid grid-cols-3 gap-2">
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Low"
value={config.low ?? ""}
onChange={(event) => onUpdate({ low: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="High"
value={config.high ?? ""}
onChange={(event) => onUpdate({ high: event.target.value })}
/>
<ConvertToField
value={config.convert_to}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: value,
})
}
/>
</div>
);
}
if (config.sampler_type === "gaussian") {
return (
<div className="grid grid-cols-3 gap-2">
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Mean"
value={config.mean ?? ""}
onChange={(event) => onUpdate({ mean: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Std"
value={config.std ?? ""}
onChange={(event) => onUpdate({ std: event.target.value })}
/>
<ConvertToField
value={config.convert_to}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: value,
})
}
/>
</div>
);
}
if (config.sampler_type === "bernoulli") {
return (
<Input
className="nodrag h-7 text-xs"
type="number"
min="0"
max="1"
step="0.01"
placeholder="p"
value={config.p ?? ""}
onChange={(event) => onUpdate({ p: event.target.value })}
/>
);
}
if (config.sampler_type === "uuid") {
return (
<Input
className="nodrag h-7 text-xs"
placeholder="UUID format"
value={config.uuid_format ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: event.target.value,
})
}
/>
);
}
return null;
}

View file

@ -0,0 +1,24 @@
import type { ComponentProps, ReactElement } from "react";
import { Handle, type HandleProps } from "@xyflow/react";
import { cn } from "@/lib/utils";
export type BaseHandleProps = HandleProps;
export function BaseHandle({
className,
children,
...props
}: ComponentProps<typeof Handle>): ReactElement {
return (
<Handle
{...props}
className={cn(
"dark:border-secondary dark:bg-secondary h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition",
className,
)}
>
{children}
</Handle>
);
}

View file

@ -0,0 +1,79 @@
import type { ComponentProps, ReactElement } from "react";
import { cn } from "@/lib/utils";
export function BaseNode({
className,
...props
}: ComponentProps<"div">): ReactElement {
return (
<div
className={cn(
"bg-card text-card-foreground relative rounded-md border",
"hover:ring-1",
"[.react-flow\\_\\_node.selected_&]:border-muted-foreground",
"[.react-flow\\_\\_node.selected_&]:shadow-lg",
className,
)}
tabIndex={0}
{...props}
/>
);
}
export function BaseNodeHeader({
className,
...props
}: ComponentProps<"header">): ReactElement {
return (
<header
{...props}
className={cn(
"mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2",
className,
)}
/>
);
}
export function BaseNodeHeaderTitle({
className,
...props
}: ComponentProps<"h3">): ReactElement {
return (
<h3
data-slot="base-node-title"
className={cn("user-select-none flex-1 font-semibold", className)}
{...props}
/>
);
}
export function BaseNodeContent({
className,
...props
}: ComponentProps<"div">): ReactElement {
return (
<div
data-slot="base-node-content"
className={cn("flex flex-col gap-y-2 p-3", className)}
{...props}
/>
);
}
export function BaseNodeFooter({
className,
...props
}: ComponentProps<"div">): ReactElement {
return (
<div
data-slot="base-node-footer"
className={cn(
"flex flex-col items-center gap-y-2 border-t px-3 pt-2 pb-3",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,130 @@
import { useMemo, type ReactElement } from "react";
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
getSmoothStepPath,
getStraightPath,
Position,
useStore,
type Edge,
type EdgeProps,
type Node,
} from "@xyflow/react";
export type DataEdge<T extends Node = Node> = Edge<{
key?: keyof T["data"];
path?: "bezier" | "smoothstep" | "step" | "straight";
}>;
export function DataEdge({
data = { path: "bezier" },
id,
markerEnd,
source,
sourcePosition,
sourceX,
sourceY,
style,
targetPosition,
targetX,
targetY,
}: EdgeProps<DataEdge>): ReactElement {
const nodeData = useStore((state) => state.nodeLookup.get(source)?.data);
const [edgePath, labelX, labelY] = getPath({
type: data.path ?? "bezier",
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const label = useMemo(() => {
if (data.key && nodeData) {
const value = nodeData[data.key];
if (typeof value === "string" || typeof value === "number") {
return value;
}
if (typeof value === "object") {
return JSON.stringify(value);
}
}
return "";
}, [data, nodeData]);
const transform = `translate(${labelX}px,${labelY}px) translate(-50%, -50%)`;
return (
<>
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
{data.key && (
<EdgeLabelRenderer>
<div
className="absolute rounded border bg-background px-1 text-foreground"
style={{ transform }}
>
<pre className="text-xs">{label}</pre>
</div>
</EdgeLabelRenderer>
)}
</>
);
}
function getPath({
type,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: {
type: "bezier" | "smoothstep" | "step" | "straight";
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition: Position;
targetPosition: Position;
}): [string, number, number, ...number[]] {
if (type === "bezier") {
return getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
}
if (type === "smoothstep") {
return getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
}
if (type === "step") {
return getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 0,
});
}
return getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
}

View file

@ -0,0 +1,49 @@
import { type ComponentProps, type ReactElement } from "react";
import { type HandleProps } from "@xyflow/react";
import { cn } from "@/lib/utils";
import { BaseHandle } from "./base-handle";
const flexDirections = {
top: "flex-col",
right: "flex-row-reverse justify-end",
bottom: "flex-col-reverse justify-end",
left: "flex-row",
};
export function LabeledHandle({
className,
labelClassName,
handleClassName,
title,
position,
...props
}: HandleProps &
ComponentProps<"div"> & {
title: string;
handleClassName?: string;
labelClassName?: string;
}): ReactElement {
const { ref, ...handleProps } = props;
return (
<div
title={title}
className={cn(
"relative flex items-center",
flexDirections[position],
className,
)}
ref={ref}
>
<BaseHandle
position={position}
className={handleClassName}
{...handleProps}
/>
<label className={cn("text-foreground px-3", labelClassName)}>
{title}
</label>
</div>
);
}

View file

@ -8,6 +8,7 @@ import type {
} from "../types";
import { nodeDataFromConfig } from "../utils";
import { removeRef, replaceRef } from "../utils/refs";
import { getConfigUiMode } from "../components/inline/inline-policy";
type NodeUpdateState = {
configs: Record<string, NodeConfig>;
@ -58,14 +59,16 @@ export function buildNodeUpdate(
type: "builder",
position: { x: 0, y: state.nextY },
data: nodeDataFromConfig(config, layoutDirection),
selected: true,
};
const mode = getConfigUiMode(config);
return {
configs: { ...state.configs, [config.id]: config },
nodes: [...state.nodes, node],
nodes: [...state.nodes.map((item) => ({ ...item, selected: false })), node],
nextId: state.nextId + 1,
nextY: state.nextY + 140,
activeConfigId: config.id,
dialogOpen: true,
dialogOpen: mode === "dialog",
};
}

View file

@ -51,6 +51,7 @@ type CanvasLabState = {
setSheetView: (view: SheetView) => void;
setProcessors: (processors: CanvasProcessorConfig[]) => void;
setDialogOpen: (open: boolean) => void;
selectConfig: (id: string) => void;
openConfig: (id: string) => void;
setLayoutDirection: (direction: LayoutDirection) => void;
applyLayout: () => void;
@ -81,6 +82,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
setSheetView: (view) => set({ sheetView: view }),
setProcessors: (processors) => set({ processors }),
setDialogOpen: (open) => set({ dialogOpen: open }),
selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }),
openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }),
setLayoutDirection: (direction) =>
set((state) => ({
@ -93,8 +95,11 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
})),
applyLayout: () =>
set((state) => {
const isTopBottom = state.layoutDirection === "TB";
const { nodes } = getLayoutedElements(state.nodes, state.edges, {
direction: state.layoutDirection,
nodesep: isTopBottom ? 120 : 80,
ranksep: isTopBottom ? 140 : 80,
});
return {
nodes: applyLayoutDirectionToNodes(

View file

@ -71,6 +71,61 @@ function isDataLane(connection: Connection): boolean {
);
}
type SingleRefRelation =
| "provider"
| "model_alias"
| "reference_column_name"
| "subcategory_parent";
function getSingleRefRelation(
source: NodeConfig,
target: NodeConfig,
): SingleRefRelation | null {
if (source.kind === "model_provider" && target.kind === "model_config") {
return "provider";
}
if (source.kind === "model_config" && target.kind === "llm") {
return "model_alias";
}
if (
source.kind === "sampler" &&
source.sampler_type === "datetime" &&
target.kind === "sampler" &&
target.sampler_type === "timedelta"
) {
return "reference_column_name";
}
if (isCategoryConfig(source) && isSubcategoryConfig(target)) {
return "subcategory_parent";
}
return null;
}
function isCompetingIncomingEdge(
edge: Edge,
targetId: string,
relation: SingleRefRelation,
configs: Record<string, NodeConfig>,
): boolean {
if (edge.target !== targetId) {
return false;
}
const source = configs[edge.source];
if (!source) {
return false;
}
if (relation === "provider") {
return source.kind === "model_provider";
}
if (relation === "model_alias") {
return source.kind === "model_config";
}
if (relation === "subcategory_parent") {
return isCategoryConfig(source);
}
return source.kind === "sampler" && source.sampler_type === "datetime";
}
export function isValidCanvasConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
@ -110,9 +165,22 @@ export function applyCanvasConnection(
return { edges };
}
const semanticRelation = isSemanticRelation(source, target);
const singleRefRelation = getSingleRefRelation(source, target);
const nextBaseEdges =
singleRefRelation
? edges.filter(
(edge) =>
!isCompetingIncomingEdge(
edge,
target.id,
singleRefRelation,
configs,
),
)
: edges;
const nextEdges = addEdge(
{ ...connection, type: semanticRelation ? "semantic" : "canvas" },
edges,
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
const next = { ...target, provider: source.name };