inline sizes, aux nodes resize, and auto layout keep nodes close logic
This commit is contained in:
parent
baa2a8426c
commit
58b40a9015
16 changed files with 568 additions and 226 deletions
|
|
@ -34,7 +34,8 @@ Owns:
|
|||
- source-of-truth state (`configs`, `nodes`, `edges`, `processors`)
|
||||
- mutation entrypoints (`updateConfig`, `onConnect`, `onNodesChange`, etc)
|
||||
- selection/dialog state (`selectConfig`, `openConfig`)
|
||||
- aux node position persistence
|
||||
- aux node position persistence (`auxNodePositions`)
|
||||
- aux node size persistence (`auxNodeSizes`)
|
||||
|
||||
Helper module:
|
||||
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts`
|
||||
|
|
@ -77,7 +78,7 @@ Owns:
|
|||
- dialog router per block type
|
||||
|
||||
Notes:
|
||||
- registry passes `modelConfigAliases` into `LlmDialog`
|
||||
- registry receives dialog option lists from page and forwards to block dialogs
|
||||
- avoids dialog -> store dependency cycle
|
||||
|
||||
Do not place here:
|
||||
|
|
@ -93,6 +94,7 @@ Files:
|
|||
Owns:
|
||||
- contract mapping between UI state and backend payload
|
||||
- edge inference fallback when import payload has no `ui.edges`
|
||||
- node width persistence via `ui.nodes[].width`
|
||||
|
||||
Do not place here:
|
||||
- ReactFlow render logic
|
||||
|
|
@ -135,6 +137,13 @@ Dialog flow:
|
|||
- node click -> `selectConfig` (no forced modal)
|
||||
- node `Details` button -> `openConfig`
|
||||
|
||||
Node sizing:
|
||||
- default builder/aux node width is `400px`
|
||||
- users can resize builder + aux nodes
|
||||
- resized width is kept in canvas state and round-tripped through import/export
|
||||
- sizing constants live in:
|
||||
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/constants.ts`
|
||||
|
||||
## 4) UI Mode Policy (Inline vs Dialog)
|
||||
|
||||
File:
|
||||
|
|
@ -185,6 +194,9 @@ Rules:
|
|||
Aux nodes:
|
||||
- are UI projections, not new payload schema entities
|
||||
- have independent drag positions persisted in Zustand `auxNodePositions`
|
||||
- have independent sizes persisted in Zustand `auxNodeSizes`
|
||||
- are resizable (same hidden-control resize UX as builder nodes)
|
||||
- are re-anchored near parent nodes after auto-layout/direction change
|
||||
|
||||
## 7) Connect Rules (single source of truth)
|
||||
|
||||
|
|
@ -211,9 +223,9 @@ Connect side-effects:
|
|||
|
||||
Current safe flow:
|
||||
- store state -> page (`configs`)
|
||||
- page derives `modelConfigAliases`
|
||||
- page passes aliases -> `ConfigDialog`
|
||||
- dialog passes aliases -> registry -> `LlmDialog`
|
||||
- page derives dialog option lists (`modelConfigAliases`, `modelProviderOptions`, `datetimeOptions`)
|
||||
- page passes these options -> `ConfigDialog`
|
||||
- dialog passes options -> registry -> block dialogs (`LlmDialog`, `ModelConfigDialog`, `TimedeltaDialog`)
|
||||
|
||||
No dialog component should import store directly.
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export function CanvasLabPage(): ReactElement {
|
|||
nodes,
|
||||
edges,
|
||||
auxNodePositions,
|
||||
auxNodeSizes,
|
||||
configs,
|
||||
processors,
|
||||
sheetView,
|
||||
|
|
@ -154,12 +155,15 @@ export function CanvasLabPage(): ReactElement {
|
|||
setLayoutDirection,
|
||||
applyLayout,
|
||||
setAuxNodePosition,
|
||||
setAuxNodeSize,
|
||||
syncAuxNodePositions,
|
||||
syncAuxNodeSizes,
|
||||
} = useCanvasLabStore(
|
||||
useShallow((state) => ({
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
auxNodePositions: state.auxNodePositions,
|
||||
auxNodeSizes: state.auxNodeSizes,
|
||||
configs: state.configs,
|
||||
processors: state.processors,
|
||||
sheetView: state.sheetView,
|
||||
|
|
@ -184,7 +188,9 @@ export function CanvasLabPage(): ReactElement {
|
|||
setLayoutDirection: state.setLayoutDirection,
|
||||
applyLayout: state.applyLayout,
|
||||
setAuxNodePosition: state.setAuxNodePosition,
|
||||
setAuxNodeSize: state.setAuxNodeSize,
|
||||
syncAuxNodePositions: state.syncAuxNodePositions,
|
||||
syncAuxNodeSizes: state.syncAuxNodeSizes,
|
||||
})),
|
||||
);
|
||||
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
|
||||
|
|
@ -215,8 +221,9 @@ export function CanvasLabPage(): ReactElement {
|
|||
configs,
|
||||
layoutDirection,
|
||||
auxNodePositions,
|
||||
auxNodeSizes,
|
||||
});
|
||||
}, [auxNodePositions, configs, edges, layoutDirection, nodes]);
|
||||
}, [auxNodePositions, auxNodeSizes, configs, edges, layoutDirection, nodes]);
|
||||
const displayNodeIds = useMemo(
|
||||
() => displayGraph.nodes.map((node) => node.id),
|
||||
[displayGraph.nodes],
|
||||
|
|
@ -224,6 +231,9 @@ export function CanvasLabPage(): ReactElement {
|
|||
useEffect(() => {
|
||||
syncAuxNodePositions(displayGraph.auxNodeIds, displayGraph.auxDefaults);
|
||||
}, [displayGraph.auxDefaults, displayGraph.auxNodeIds, syncAuxNodePositions]);
|
||||
useEffect(() => {
|
||||
syncAuxNodeSizes(displayGraph.auxNodeIds);
|
||||
}, [displayGraph.auxNodeIds, syncAuxNodeSizes]);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_: unknown, node: Node<CanvasNodeData | CanvasAuxNodeData>) => {
|
||||
|
|
@ -240,13 +250,25 @@ export function CanvasLabPage(): ReactElement {
|
|||
for (const change of changes) {
|
||||
if (
|
||||
!("id" in change) ||
|
||||
change.type !== "position" ||
|
||||
!change.id.startsWith("aux-") ||
|
||||
!change.position
|
||||
!change.id.startsWith("aux-")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
setAuxNodePosition(change.id, change.position);
|
||||
if (change.type === "position" && change.position) {
|
||||
setAuxNodePosition(change.id, change.position);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
change.type === "dimensions" &&
|
||||
change.dimensions &&
|
||||
change.dimensions.width > 0 &&
|
||||
change.dimensions.height > 0
|
||||
) {
|
||||
setAuxNodeSize(change.id, {
|
||||
width: change.dimensions.width,
|
||||
height: change.dimensions.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
const next = changes.filter(
|
||||
(change): change is NodeChange<CanvasBuilderNode> =>
|
||||
|
|
@ -256,7 +278,7 @@ export function CanvasLabPage(): ReactElement {
|
|||
onNodesChange(next);
|
||||
}
|
||||
},
|
||||
[baseNodeIds, onNodesChange, setAuxNodePosition],
|
||||
[baseNodeIds, onNodesChange, setAuxNodePosition, setAuxNodeSize],
|
||||
);
|
||||
|
||||
const handleEdgesChange = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Handle,
|
||||
NodeResizer,
|
||||
Position,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import { memo, type ReactElement } from "react";
|
||||
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
|
||||
import { useCanvasLabStore } from "../stores/canvas-lab";
|
||||
import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
|
||||
import { HANDLE_IDS } from "../utils/handles";
|
||||
|
|
@ -49,7 +56,9 @@ function updateOptionAt(
|
|||
);
|
||||
}
|
||||
|
||||
function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | null {
|
||||
function AuxNodeBase({
|
||||
data,
|
||||
}: NodeProps<CanvasAuxNodeType>): ReactElement | null {
|
||||
const config = useCanvasLabStore((state) => state.configs[data.llmId]);
|
||||
const updateConfig = useCanvasLabStore((state) => state.updateConfig);
|
||||
|
||||
|
|
@ -63,13 +72,25 @@ function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | nul
|
|||
if (data.kind === "llm-prompt-input") {
|
||||
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
|
||||
return (
|
||||
<BaseNode className="corner-squircle min-w-[250px] rounded-lg border-border/60 bg-card shadow-sm">
|
||||
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
|
||||
<NodeResizer
|
||||
isVisible={true}
|
||||
minWidth={MIN_NODE_WIDTH}
|
||||
minHeight={120}
|
||||
maxWidth={MAX_NODE_WIDTH}
|
||||
maxHeight={520}
|
||||
color="var(--primary)"
|
||||
lineClassName="!border-transparent !shadow-none"
|
||||
lineStyle={{ opacity: 0 }}
|
||||
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
|
||||
handleStyle={{ opacity: 0 }}
|
||||
/>
|
||||
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
|
||||
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
|
||||
</BaseNodeHeader>
|
||||
<BaseNodeContent className="px-3 py-2">
|
||||
<Textarea
|
||||
className="nodrag min-h-[78px] text-xs"
|
||||
className="nodrag max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
updateConfig(data.llmId, {
|
||||
|
|
@ -128,7 +149,19 @@ function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | nul
|
|||
};
|
||||
|
||||
return (
|
||||
<BaseNode className="corner-squircle min-w-[280px] rounded-lg border-border/60 bg-card shadow-sm">
|
||||
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
|
||||
<NodeResizer
|
||||
isVisible={true}
|
||||
minWidth={MIN_NODE_WIDTH}
|
||||
minHeight={120}
|
||||
maxWidth={MAX_NODE_WIDTH}
|
||||
maxHeight={640}
|
||||
color="var(--primary)"
|
||||
lineClassName="!border-transparent !shadow-none"
|
||||
lineStyle={{ opacity: 0 }}
|
||||
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
|
||||
handleStyle={{ opacity: 0 }}
|
||||
/>
|
||||
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
|
||||
<BaseNodeHeaderTitle className="text-xs">
|
||||
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}
|
||||
|
|
@ -139,13 +172,13 @@ function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | nul
|
|||
</BaseNodeHeader>
|
||||
<BaseNodeContent className="gap-2 px-3 py-2">
|
||||
<Input
|
||||
className="nodrag h-7 text-xs"
|
||||
className="nodrag h-7 w-full text-xs"
|
||||
placeholder="Score name"
|
||||
value={score.name}
|
||||
onChange={(event) => updateScore({ name: event.target.value })}
|
||||
/>
|
||||
<Textarea
|
||||
className="nodrag min-h-[56px] text-xs"
|
||||
className="nodrag max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
|
||||
placeholder="Score description"
|
||||
value={score.description}
|
||||
onChange={(event) => updateScore({ description: event.target.value })}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import { memo, type ReactElement, useEffect } from "react";
|
||||
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
|
||||
import { useCanvasLabStore } from "../stores/canvas-lab";
|
||||
import type {
|
||||
CanvasNode as CanvasNodeType,
|
||||
|
|
@ -270,7 +271,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
|
|||
return (
|
||||
<div className="space-y-1 pb-1">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="pointer-events-none relative pl-3">
|
||||
<div key={item.id} className="pointer-events-none relative min-w-0 pl-3">
|
||||
<Handle
|
||||
id={item.id}
|
||||
type="target"
|
||||
|
|
@ -278,7 +279,9 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
|
|||
className="pointer-events-auto !size-2 !border-border !bg-background"
|
||||
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">{item.label}</span>
|
||||
<span className="block truncate text-[10px] text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -318,12 +321,12 @@ function CanvasNodeBase({
|
|||
const llmInputHandles = getLlmInputHandleItems(config);
|
||||
|
||||
return (
|
||||
<BaseNode className="corner-squircle relative min-w-[260px] overflow-visible rounded-lg border-border/60 shadow-sm">
|
||||
<BaseNode className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm">
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={260}
|
||||
minWidth={MIN_NODE_WIDTH}
|
||||
minHeight={120}
|
||||
maxWidth={760}
|
||||
maxWidth={MAX_NODE_WIDTH}
|
||||
maxHeight={520}
|
||||
color="var(--primary)"
|
||||
lineClassName="!border-transparent !shadow-none"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ExpressionConfig, ExpressionDtype } from "../../types";
|
||||
import { InlineField } from "./inline-field";
|
||||
|
||||
type InlineExpressionProps = {
|
||||
config: ExpressionConfig;
|
||||
|
|
@ -21,30 +22,34 @@ export function InlineExpression({
|
|||
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 className="grid gap-3 sm:grid-cols-[130px_1fr]">
|
||||
<InlineField label="Output type">
|
||||
<Select
|
||||
value={config.dtype}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({ dtype: value as ExpressionDtype })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="nodrag h-8 w-full text-xs">
|
||||
<SelectValue placeholder="dtype" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DTYPE_OPTIONS.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</InlineField>
|
||||
<InlineField label="Expression">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="{{ column_name }}"
|
||||
value={config.expr}
|
||||
onChange={(event) => onUpdate({ expr: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
type InlineFieldProps = {
|
||||
label: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function InlineField({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: InlineFieldProps): ReactElement {
|
||||
return (
|
||||
<div className={cn("grid gap-1.5", className)}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import type { ReactElement } from "react";
|
||||
import type { LlmConfig } from "../../types";
|
||||
import { InlineField } from "./inline-field";
|
||||
|
||||
type InlineLlmProps = {
|
||||
config: LlmConfig;
|
||||
|
|
@ -37,39 +38,43 @@ 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) =>
|
||||
<div className="space-y-3">
|
||||
<InlineField label="Model alias">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="Model alias"
|
||||
value={config.model_alias}
|
||||
onChange={(event) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
code_lang: value,
|
||||
model_alias: event.target.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>
|
||||
/>
|
||||
</InlineField>
|
||||
{isCode && (
|
||||
<InlineField label="Code language">
|
||||
<Select
|
||||
value={config.code_lang?.trim() || "python"}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
code_lang: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="nodrag h-8 w-full text-xs">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CODE_LANG_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang} value={lang}>
|
||||
{lang}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</InlineField>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Prompt/System are edited in dialog or linked input nodes.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ModelConfig, ModelProviderConfig } from "../../types";
|
||||
import { InlineField } from "./inline-field";
|
||||
|
||||
type InlineModelPatch = Partial<ModelProviderConfig> | Partial<ModelConfig>;
|
||||
|
||||
|
|
@ -12,54 +13,64 @@ type InlineModelProps = {
|
|||
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 className="grid gap-3 sm:grid-cols-2">
|
||||
<InlineField label="Provider type">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="openai-compatible"
|
||||
value={props.config.provider_type}
|
||||
onChange={(event) =>
|
||||
props.onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
provider_type: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Endpoint">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={props.config.endpoint}
|
||||
onChange={(event) => props.onUpdate({ endpoint: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
</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 className="grid gap-3 sm:grid-cols-2">
|
||||
<InlineField label="Provider">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="provider alias"
|
||||
value={props.config.provider}
|
||||
onChange={(event) => props.onUpdate({ provider: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Model">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="gpt-4o-mini"
|
||||
value={props.config.model}
|
||||
onChange={(event) => props.onUpdate({ model: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Temperature" className="sm:col-span-2">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
type="number"
|
||||
placeholder="0.7"
|
||||
value={props.config.inference_temperature ?? ""}
|
||||
onChange={(event) =>
|
||||
props.onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
inference_temperature: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import type { ReactElement } from "react";
|
||||
import type { SamplerConfig } from "../../types";
|
||||
import { InlineField } from "./inline-field";
|
||||
|
||||
type InlineSamplerProps = {
|
||||
config: SamplerConfig;
|
||||
|
|
@ -30,7 +31,7 @@ function ConvertToField({
|
|||
onValueChange(next === "none" ? undefined : (next as ConvertTo))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="nodrag h-7 text-xs">
|
||||
<SelectTrigger className="nodrag h-8 w-full text-xs">
|
||||
<SelectValue placeholder="Convert" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -49,92 +50,108 @@ export function InlineSampler({
|
|||
}: 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 className="grid gap-3 sm:grid-cols-3">
|
||||
<InlineField label="Low">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={config.low ?? ""}
|
||||
onChange={(event) => onUpdate({ low: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="High">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
type="number"
|
||||
placeholder="100"
|
||||
value={config.high ?? ""}
|
||||
onChange={(event) => onUpdate({ high: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Convert to">
|
||||
<ConvertToField
|
||||
value={config.convert_to}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
convert_to: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</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 className="grid gap-3 sm:grid-cols-3">
|
||||
<InlineField label="Mean">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={config.mean ?? ""}
|
||||
onChange={(event) => onUpdate({ mean: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Std dev">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
value={config.std ?? ""}
|
||||
onChange={(event) => onUpdate({ std: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Convert to">
|
||||
<ConvertToField
|
||||
value={config.convert_to}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
convert_to: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</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 })}
|
||||
/>
|
||||
<InlineField label="Probability (p)">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.5"
|
||||
value={config.p ?? ""}
|
||||
onChange={(event) => onUpdate({ p: event.target.value })}
|
||||
/>
|
||||
</InlineField>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<InlineField label="UUID format">
|
||||
<Input
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="uuid4"
|
||||
value={config.uuid_format ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
uuid_format: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
4
studio/frontend/src/features/canvas-lab/constants.ts
Normal file
4
studio/frontend/src/features/canvas-lab/constants.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const DEFAULT_NODE_WIDTH = 400;
|
||||
export const DEFAULT_NODE_HEIGHT = 120;
|
||||
export const MIN_NODE_WIDTH = 260;
|
||||
export const MAX_NODE_WIDTH = 900;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { type Edge, addEdge } from "@xyflow/react";
|
||||
import { DEFAULT_NODE_WIDTH } from "../constants";
|
||||
import type {
|
||||
CanvasNode,
|
||||
LayoutDirection,
|
||||
|
|
@ -61,6 +62,7 @@ export function buildNodeUpdate(
|
|||
type: "builder",
|
||||
position: { x: 0, y: state.nextY },
|
||||
data: nodeDataFromConfig(config, layoutDirection),
|
||||
style: { width: DEFAULT_NODE_WIDTH },
|
||||
selected: true,
|
||||
};
|
||||
const mode = getConfigUiMode(config);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ type CanvasLabState = {
|
|||
nodes: CanvasNode[];
|
||||
edges: Edge[];
|
||||
auxNodePositions: Record<string, XYPosition>;
|
||||
auxNodeSizes: Record<string, { width: number; height: number }>;
|
||||
configs: Record<string, NodeConfig>;
|
||||
processors: CanvasProcessorConfig[];
|
||||
sheetView: SheetView;
|
||||
|
|
@ -62,10 +63,15 @@ type CanvasLabState = {
|
|||
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
|
||||
loadCanvas: (snapshot: CanvasSnapshot) => void;
|
||||
setAuxNodePosition: (id: string, position: XYPosition) => void;
|
||||
setAuxNodeSize: (
|
||||
id: string,
|
||||
size: { width: number; height: number },
|
||||
) => void;
|
||||
syncAuxNodePositions: (
|
||||
activeIds: string[],
|
||||
defaults: Record<string, XYPosition>,
|
||||
) => void;
|
||||
syncAuxNodeSizes: (activeIds: string[]) => void;
|
||||
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
|
|
@ -76,6 +82,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
nodes: [],
|
||||
edges: [],
|
||||
auxNodePositions: {},
|
||||
auxNodeSizes: {},
|
||||
configs: {},
|
||||
processors: [],
|
||||
sheetView: "root",
|
||||
|
|
@ -92,6 +99,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
setLayoutDirection: (direction) =>
|
||||
set((state) => ({
|
||||
layoutDirection: direction,
|
||||
auxNodePositions: {},
|
||||
nodes: applyLayoutDirectionToNodes(
|
||||
state.nodes,
|
||||
state.configs,
|
||||
|
|
@ -107,6 +115,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
ranksep: isTopBottom ? 140 : 80,
|
||||
});
|
||||
return {
|
||||
auxNodePositions: {},
|
||||
nodes: applyLayoutDirectionToNodes(
|
||||
nodes,
|
||||
state.configs,
|
||||
|
|
@ -187,6 +196,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
nextId: snapshot.nextId,
|
||||
nextY: snapshot.nextY,
|
||||
auxNodePositions: {},
|
||||
auxNodeSizes: {},
|
||||
activeConfigId: null,
|
||||
dialogOpen: false,
|
||||
sheetView: "root",
|
||||
|
|
@ -204,6 +214,21 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
},
|
||||
};
|
||||
}),
|
||||
setAuxNodeSize: (id, size) =>
|
||||
set((state) => {
|
||||
const width = Math.max(1, size.width);
|
||||
const height = Math.max(1, size.height);
|
||||
const current = state.auxNodeSizes[id];
|
||||
if (current && current.width === width && current.height === height) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
auxNodeSizes: {
|
||||
...state.auxNodeSizes,
|
||||
[id]: { width, height },
|
||||
},
|
||||
};
|
||||
}),
|
||||
syncAuxNodePositions: (activeIds, defaults) =>
|
||||
set((state) => {
|
||||
const nextPositions: Record<string, XYPosition> = {};
|
||||
|
|
@ -232,6 +257,29 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
}
|
||||
return state;
|
||||
}),
|
||||
syncAuxNodeSizes: (activeIds) =>
|
||||
set((state) => {
|
||||
const activeSet = new Set(activeIds);
|
||||
const nextSizes: Record<string, { width: number; height: number }> = {};
|
||||
for (const [id, size] of Object.entries(state.auxNodeSizes)) {
|
||||
if (activeSet.has(id)) {
|
||||
nextSizes[id] = size;
|
||||
}
|
||||
}
|
||||
const prevIds = Object.keys(state.auxNodeSizes);
|
||||
const nextIds = Object.keys(nextSizes);
|
||||
if (prevIds.length !== nextIds.length) {
|
||||
return { auxNodeSizes: nextSizes };
|
||||
}
|
||||
for (const id of nextIds) {
|
||||
const prev = state.auxNodeSizes[id];
|
||||
const next = nextSizes[id];
|
||||
if (!(prev && prev.width === next.width && prev.height === next.height)) {
|
||||
return { auxNodeSizes: nextSizes };
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}),
|
||||
updateConfig: (id, patch) => {
|
||||
const applyUpdate = (state: CanvasLabState) => {
|
||||
const current = state.configs[id];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Edge, Node, XYPosition } from "@xyflow/react";
|
||||
import type { CanvasAuxNodeData } from "../../components/canvas-aux-node";
|
||||
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../../constants";
|
||||
import type { CanvasNode, LayoutDirection, NodeConfig } from "../../types";
|
||||
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../handles";
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ type DisplayGraphInput = {
|
|||
configs: Record<string, NodeConfig>;
|
||||
layoutDirection: LayoutDirection;
|
||||
auxNodePositions: Record<string, XYPosition>;
|
||||
auxNodeSizes: Record<string, { width: number; height: number }>;
|
||||
};
|
||||
|
||||
export type DisplayGraph = {
|
||||
|
|
@ -24,19 +26,78 @@ type AuxNodeItem = {
|
|||
data: CanvasAuxNodeData;
|
||||
};
|
||||
|
||||
function getNodeWidth(node: Node): number {
|
||||
if (typeof node.width === "number" && Number.isFinite(node.width)) {
|
||||
return node.width;
|
||||
}
|
||||
if (typeof node.style?.width === "number" && Number.isFinite(node.style.width)) {
|
||||
return node.style.width;
|
||||
}
|
||||
if (typeof node.style?.width === "string") {
|
||||
const parsed = Number.parseFloat(node.style.width);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof node.measured?.width === "number" &&
|
||||
Number.isFinite(node.measured.width)
|
||||
) {
|
||||
return node.measured.width;
|
||||
}
|
||||
return DEFAULT_NODE_WIDTH;
|
||||
}
|
||||
|
||||
function getNodeHeight(node: Node): number {
|
||||
if (typeof node.height === "number" && Number.isFinite(node.height)) {
|
||||
return node.height;
|
||||
}
|
||||
if (typeof node.style?.height === "number" && Number.isFinite(node.style.height)) {
|
||||
return node.style.height;
|
||||
}
|
||||
if (typeof node.style?.height === "string") {
|
||||
const parsed = Number.parseFloat(node.style.height);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof node.measured?.height === "number" &&
|
||||
Number.isFinite(node.measured.height)
|
||||
) {
|
||||
return node.measured.height;
|
||||
}
|
||||
return DEFAULT_NODE_HEIGHT;
|
||||
}
|
||||
|
||||
export function deriveDisplayGraph({
|
||||
nodes,
|
||||
edges,
|
||||
configs,
|
||||
layoutDirection,
|
||||
auxNodePositions,
|
||||
auxNodeSizes,
|
||||
}: DisplayGraphInput): DisplayGraph {
|
||||
const displayNodes = nodes.map((node) => {
|
||||
const hasWidth =
|
||||
typeof node.width === "number" ||
|
||||
typeof node.style?.width === "number" ||
|
||||
(typeof node.style?.width === "string" &&
|
||||
Number.isFinite(Number.parseFloat(node.style.width)));
|
||||
if (hasWidth) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
style: { ...node.style, width: DEFAULT_NODE_WIDTH },
|
||||
};
|
||||
});
|
||||
const auxNodes: Node<CanvasAuxNodeData>[] = [];
|
||||
const auxEdges: Edge[] = [];
|
||||
const auxDefaults: Record<string, XYPosition> = {};
|
||||
const auxNodeIds: string[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
for (const node of displayNodes) {
|
||||
const config = configs[node.id];
|
||||
if (!(config && config.kind === "llm")) {
|
||||
continue;
|
||||
|
|
@ -91,35 +152,102 @@ export function deriveDisplayGraph({
|
|||
continue;
|
||||
}
|
||||
|
||||
const itemSpan = 140;
|
||||
const itemCenterOffset = ((items.length - 1) * itemSpan) / 2;
|
||||
const horizontalSpan = 300;
|
||||
const horizontalCenterOffset = ((items.length - 1) * horizontalSpan) / 2;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const parentWidth = getNodeWidth(node);
|
||||
const parentHeight = getNodeHeight(node);
|
||||
const itemsWithLayout = items.map((item) => {
|
||||
const auxId = `aux-${node.id}-${item.key}`;
|
||||
const defaultPosition =
|
||||
llmDirection === "TB"
|
||||
? {
|
||||
x: node.position.x + index * horizontalSpan - horizontalCenterOffset,
|
||||
y: node.position.y - 210,
|
||||
}
|
||||
: {
|
||||
x: node.position.x - 330,
|
||||
y: node.position.y + index * itemSpan - itemCenterOffset,
|
||||
};
|
||||
const position = auxNodePositions[auxId] ?? defaultPosition;
|
||||
const savedSize = auxNodeSizes[auxId];
|
||||
return {
|
||||
item,
|
||||
auxId,
|
||||
width: savedSize?.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: savedSize?.height ?? DEFAULT_NODE_HEIGHT,
|
||||
};
|
||||
});
|
||||
|
||||
auxNodeIds.push(auxId);
|
||||
if (!auxNodePositions[auxId]) {
|
||||
auxDefaults[auxId] = defaultPosition;
|
||||
const gap = 24;
|
||||
const sideOffset = 48;
|
||||
|
||||
if (llmDirection === "TB") {
|
||||
const totalWidth =
|
||||
itemsWithLayout.reduce((sum, entry) => sum + entry.width, 0) +
|
||||
(itemsWithLayout.length - 1) * gap;
|
||||
const startX = node.position.x + (parentWidth - totalWidth) / 2;
|
||||
let xCursor = startX;
|
||||
|
||||
for (const entry of itemsWithLayout) {
|
||||
const defaultPosition = {
|
||||
x: xCursor,
|
||||
y: node.position.y - entry.height - sideOffset,
|
||||
};
|
||||
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
|
||||
xCursor += entry.width + gap;
|
||||
|
||||
auxNodeIds.push(entry.auxId);
|
||||
if (!auxNodePositions[entry.auxId]) {
|
||||
auxDefaults[entry.auxId] = defaultPosition;
|
||||
}
|
||||
|
||||
auxNodes.push({
|
||||
id: entry.auxId,
|
||||
type: "aux",
|
||||
data: entry.item.data,
|
||||
position,
|
||||
style: {
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
focusable: true,
|
||||
connectable: false,
|
||||
});
|
||||
|
||||
auxEdges.push({
|
||||
id: `e-${entry.auxId}-${node.id}`,
|
||||
source: entry.auxId,
|
||||
sourceHandle: HANDLE_IDS.llmInputOut,
|
||||
target: node.id,
|
||||
targetHandle: entry.item.targetHandle,
|
||||
type: "canvas",
|
||||
data: { path: "auto" },
|
||||
selectable: false,
|
||||
focusable: false,
|
||||
style: { strokeWidth: 1.5, stroke: "var(--border)" },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalHeight =
|
||||
itemsWithLayout.reduce((sum, entry) => sum + entry.height, 0) +
|
||||
(itemsWithLayout.length - 1) * gap;
|
||||
const maxWidth = Math.max(...itemsWithLayout.map((entry) => entry.width));
|
||||
const baseX = node.position.x - maxWidth - sideOffset;
|
||||
let yCursor = node.position.y + (parentHeight - totalHeight) / 2;
|
||||
|
||||
for (const entry of itemsWithLayout) {
|
||||
const defaultPosition = {
|
||||
x: baseX + (maxWidth - entry.width),
|
||||
y: yCursor,
|
||||
};
|
||||
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
|
||||
yCursor += entry.height + gap;
|
||||
|
||||
auxNodeIds.push(entry.auxId);
|
||||
if (!auxNodePositions[entry.auxId]) {
|
||||
auxDefaults[entry.auxId] = defaultPosition;
|
||||
}
|
||||
|
||||
auxNodes.push({
|
||||
id: auxId,
|
||||
id: entry.auxId,
|
||||
type: "aux",
|
||||
data: item.data,
|
||||
data: entry.item.data,
|
||||
position,
|
||||
style: {
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
focusable: true,
|
||||
|
|
@ -127,22 +255,22 @@ export function deriveDisplayGraph({
|
|||
});
|
||||
|
||||
auxEdges.push({
|
||||
id: `e-${auxId}-${node.id}`,
|
||||
source: auxId,
|
||||
id: `e-${entry.auxId}-${node.id}`,
|
||||
source: entry.auxId,
|
||||
sourceHandle: HANDLE_IDS.llmInputOut,
|
||||
target: node.id,
|
||||
targetHandle: item.targetHandle,
|
||||
targetHandle: entry.item.targetHandle,
|
||||
type: "canvas",
|
||||
data: { path: "auto" },
|
||||
selectable: false,
|
||||
focusable: false,
|
||||
style: { strokeWidth: 1.5, stroke: "var(--border)" },
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [...nodes, ...auxNodes],
|
||||
nodes: [...displayNodes, ...auxNodes],
|
||||
edges: [...edges, ...auxEdges],
|
||||
auxNodeIds,
|
||||
auxDefaults,
|
||||
|
|
|
|||
|
|
@ -149,8 +149,8 @@ export function importCanvasPayload(input: string): ImportResult {
|
|||
return { errors, snapshot: null };
|
||||
}
|
||||
|
||||
const { positions, edges: uiEdges } = parseUi(ui);
|
||||
const nodes = buildNodes(configs, positions);
|
||||
const { layouts, edges: uiEdges } = parseUi(ui);
|
||||
const nodes = buildNodes(configs, layouts);
|
||||
const edges = buildEdges(configs, nameToId, uiEdges);
|
||||
|
||||
const maxY = nodes.reduce(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CanvasNode, NodeConfig } from "../../types";
|
||||
import { DEFAULT_NODE_WIDTH } from "../../constants";
|
||||
import { nodeDataFromConfig } from "../index";
|
||||
import { isRecord, readString } from "./helpers";
|
||||
|
||||
|
|
@ -10,10 +11,10 @@ type UiInput = {
|
|||
export function parseUi(
|
||||
ui: UiInput | null,
|
||||
): {
|
||||
positions: Map<string, { x: number; y: number }>;
|
||||
layouts: Map<string, { x: number; y: number; width?: number }>;
|
||||
edges: Array<{ from: string; to: string; type?: string }> | null;
|
||||
} {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const layouts = new Map<string, { x: number; y: number; width?: number }>();
|
||||
const edges: Array<{ from: string; to: string; type?: string }> = [];
|
||||
if (ui && Array.isArray(ui.nodes)) {
|
||||
for (const node of ui.nodes) {
|
||||
|
|
@ -21,8 +22,13 @@ export function parseUi(
|
|||
const id = readString(node.id);
|
||||
const x = typeof node.x === "number" ? node.x : null;
|
||||
const y = typeof node.y === "number" ? node.y : null;
|
||||
const width = typeof node.width === "number" ? node.width : null;
|
||||
if (id && x !== null && y !== null) {
|
||||
positions.set(id, { x, y });
|
||||
layouts.set(id, {
|
||||
x,
|
||||
y,
|
||||
...(width && width > 0 ? { width } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,21 +48,26 @@ export function parseUi(
|
|||
}
|
||||
}
|
||||
}
|
||||
return { positions, edges: edges.length > 0 ? edges : null };
|
||||
return { layouts, edges: edges.length > 0 ? edges : null };
|
||||
}
|
||||
|
||||
export function buildNodes(
|
||||
configs: NodeConfig[],
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
layouts: Map<string, { x: number; y: number; width?: number }>,
|
||||
): CanvasNode[] {
|
||||
return configs.map((config, index) => {
|
||||
const position =
|
||||
positions.get(config.name) ?? ({ x: 0, y: index * 140 } as const);
|
||||
const fallbackLayout: { x: number; y: number; width?: number } = {
|
||||
x: 0,
|
||||
y: index * 140,
|
||||
};
|
||||
const layout =
|
||||
layouts.get(config.name) ?? fallbackLayout;
|
||||
return {
|
||||
id: config.id,
|
||||
type: "builder",
|
||||
position,
|
||||
position: { x: layout.x, y: layout.y },
|
||||
data: nodeDataFromConfig(config),
|
||||
style: { width: layout.width ?? DEFAULT_NODE_WIDTH },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,22 @@ import {
|
|||
validateUsedProviders,
|
||||
} from "./validate";
|
||||
|
||||
function getNodeWidth(node: CanvasNode): number | null {
|
||||
if (typeof node.width === "number" && Number.isFinite(node.width)) {
|
||||
return node.width;
|
||||
}
|
||||
if (typeof node.style?.width === "number" && Number.isFinite(node.style.width)) {
|
||||
return node.style.width;
|
||||
}
|
||||
if (typeof node.style?.width === "string") {
|
||||
const parsed = Number.parseFloat(node.style.width);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build
|
||||
export function buildCanvasPayload(
|
||||
configs: Record<string, NodeConfig>,
|
||||
|
|
@ -100,11 +116,13 @@ export function buildCanvasPayload(
|
|||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
const width = getNodeWidth(node);
|
||||
return [
|
||||
{
|
||||
id: config.name,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
...(width !== null ? { width } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue