inline dialogs and react flow ui refactor WIP p1
This commit is contained in:
parent
271ddcfb4a
commit
b15f4e7ad5
15 changed files with 1027 additions and 78 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue