cleanup and restctucture

This commit is contained in:
shine1i 2026-02-06 12:54:55 +01:00
commit baa2a8426c
13 changed files with 852 additions and 827 deletions

View file

@ -3,32 +3,109 @@
Root:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab`
This doc reflects current code shape (React Flow UI node/edge shell + inline/dialog split + derived aux nodes).
Goal of this layout:
- simple ownership
- low coupling
- predictable edit points
- behavior driven by config + store, not view side-effects
## 1) High-level flow
## 1) Ownership Map (hard boundaries)
1. Page shell + React Flow canvas:
### Page orchestration boundary
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx`
2. Block picker sheet (plus/import/copy floating controls):
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx`
3. Zustand state + all graph/config mutations:
Owns:
- React Flow mount + wiring
- selector/orchestration glue from Zustand
- derived display graph (`deriveDisplayGraph`)
- modal/sheet open-close UI state
Do not place here:
- config mutation rules
- connection legality rules
- payload/import mapping
### Store mutation boundary
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts`
4. Connection validation + edge side-effects:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts`
5. Export/payload map:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/build-payload.ts`
6. Import/rebuild:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts`
## 2) Core types
Owns:
- source-of-truth state (`configs`, `nodes`, `edges`, `processors`)
- mutation entrypoints (`updateConfig`, `onConnect`, `onNodesChange`, etc)
- selection/dialog state (`selectConfig`, `openConfig`)
- aux node position persistence
Source of truth:
Helper module:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts`
Owns:
- pure relation sync helpers (edge/config sync)
- rename/remove propagation helpers
- node data/layout transformation helpers
Do not place in helpers:
- React component logic
- network/API calls
### Graph rules boundary
Files:
- `.../utils/graph/canvas-connection.ts`
- `.../utils/graph/derive-display-graph.ts`
- `.../utils/graph.ts` (re-export shim)
`canvas-connection.ts` owns:
- valid/invalid connection rules
- connect side-effects (config updates from edges)
- single-incoming relation enforcement
`derive-display-graph.ts` owns:
- derived aux nodes/edges (LLM prompt/system/scorer projections)
- default aux positioning
Do not place here:
- dialog form logic
- block creation defaults
### Registry boundary
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx`
Owns:
- block metadata for sheet
- config factory per block
- dialog router per block type
Notes:
- registry passes `modelConfigAliases` into `LlmDialog`
- avoids dialog -> store dependency cycle
Do not place here:
- cross-node graph mutation logic
- payload/export code
### Import/export boundary
Files:
- `.../utils/payload/build-payload.ts`
- `.../utils/import/importer.ts`
- `.../utils/import/edges.ts`
Owns:
- contract mapping between UI state and backend payload
- edge inference fallback when import payload has no `ui.edges`
Do not place here:
- ReactFlow render logic
- store actions
## 2) Core Types
Source:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts`
`NodeConfig` union:
`NodeConfig` is business truth:
```ts
export type NodeConfig =
type NodeConfig =
| SamplerConfig
| LlmConfig
| ExpressionConfig
@ -36,261 +113,140 @@ export type NodeConfig =
| ModelConfig;
```
`CanvasNodeData` is derived from config (`nodeDataFromConfig`), not edited directly.
`CanvasNodeData` is derived display data (`nodeDataFromConfig`), not primary state.
## 3) Entrypoint wiring
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx`
Current wiring:
## 3) React Flow Composition
Page wiring:
```ts
const NODE_TYPES: NodeTypes = { builder: CanvasNode, aux: CanvasAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: CanvasSemanticEdge };
const NODE_TYPES = { builder: CanvasNode, aux: CanvasAuxNode };
const EDGE_TYPES = { canvas: DataEdge, semantic: CanvasSemanticEdge };
```
Default data edge style uses auto path selection:
Data edges use auto path mode:
```ts
defaultEdgeOptions={{
type: "canvas",
data: { key: "name", path: "auto" },
style: { strokeWidth: 1.5, stroke: "var(--border)" },
}}
```
Node click selects config (`selectConfig`), does not auto-open dialog.
Dialog opens via node `Details` button (`openConfig`) or explicit flows.
Aux nodes (prompt/system/scorer) are derived in page and mounted as `type: "aux"`.
Dialog flow:
- node click -> `selectConfig` (no forced modal)
- node `Details` button -> `openConfig`
## 4) Registry-driven block system
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx`
Registry owns:
- block metadata for sheet (title/icon/description)
- config factory (`createConfig`)
- dialog renderer (`renderDialog`)
If adding new `NodeConfig.kind`, keep `getBlockDefinitionForConfig` coverage complete.
## 5) Store responsibilities
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts`
Store owns:
- `nodes`, `edges`, `configs`, `processors`
- `auxNodePositions` (derived aux-node position persistence)
- add/update/remove/connect logic
- `layoutDirection` + dagre apply-layout
- config selection/dialog state
Current config-selection API:
- `selectConfig(id)`: select node config, keep dialog closed
- `openConfig(id)`: select + open modal
Aux-node API:
- `setAuxNodePosition(id, position)`: persist independent drag position
- `syncAuxNodePositions(activeIds, defaults)`: cleanup stale aux positions + seed new defaults
Add-node behavior is mode-aware via:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts`
New nodes:
- become selected
- set `activeConfigId`
- open dialog only for dialog-first config modes
## 6) Inline vs dialog config policy
## 4) UI Mode Policy (Inline vs Dialog)
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts`
Inline mode:
Inline:
- sampler: `uniform`, `gaussian`, `bernoulli`, `uuid`
- `model_provider`
- `model_config`
- `model_provider`, `model_config`
- llm: `text`, `code`
- `expression`
Dialog mode:
Dialog:
- sampler: `category`, `subcategory`, `datetime`, `timedelta`, `person`, `person_from_faker`
- llm: `structured`, `judge`
Inline editors:
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-sampler.tsx`
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-model.tsx`
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-llm.tsx`
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-expression.tsx`
- `.../components/inline/inline-sampler.tsx`
- `.../components/inline/inline-model.tsx`
- `.../components/inline/inline-llm.tsx`
- `.../components/inline/inline-expression.tsx`
LLM inline scope:
- text: `model_alias` only
- code: `model_alias`, `code_lang`
- prompt/system prompt edited in dialog or spawned aux prompt nodes
## 7) Node UI architecture (React Flow UI shell)
## 5) Handle Contract (stable IDs)
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx`
Node shell uses feature-local RF UI primitives:
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/rf-ui/base-node.tsx`
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/rf-ui/labeled-handle.tsx`
Current node UX:
- `corner-squircle` + `rounded-lg` container
- inline editor shown by default for inline-capable configs
- summary text for dialog-first configs
- `Details` button opens modal dialog
- node resizer logic enabled (`NodeResizer`), visuals hidden (no corner/box affordance)
- LLM input handles (system/prompt/scorers) render in dedicated content rows (no overlay on inline controls)
## 8) Handles + layout direction
Handle IDs (kept stable):
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/handles.ts`
```ts
dataIn: "data-in"
dataOut: "data-out"
semanticIn: "semantic-in"
semanticOut: "semantic-out"
llmPromptIn: "llm-prompt-in"
llmSystemIn: "llm-system-in"
llmInputOut: "llm-input-out"
getLlmJudgeScoreHandleId(index): `llm-judge-score-in-${index}`
```
Stable IDs:
- `data-in`, `data-out`
- `semantic-in`, `semantic-out`
- `llm-prompt-in`, `llm-system-in`, `llm-input-out`
- `llm-judge-score-in-${index}`
`canvas-node.tsx` switches handle positions by layout direction:
- `LR`: data left/right, semantic top/bottom
- `TB`: data top/bottom, semantic left/right
These are contract-level values for:
- connection validity
- edge inference
- payload consistency
After direction toggle or auto-layout, page refreshes node internals to avoid stale edge anchor offsets.
## 6) LLM Derived Aux Nodes
## 9) Edge architecture
Behavior source:
`.../utils/graph/derive-display-graph.ts`
Data edge:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/rf-ui/data-edge.tsx`
Rules:
- non-empty `prompt` => spawn editable prompt aux node
- non-empty `system_prompt` => spawn editable system aux node
- `llm_type === "judge"` => spawn scorer aux nodes from `scores[]`
Features:
- label from source node data key
- `path: "auto"` chooses straight vs smoothstep/bezier based on geometry/positions
Aux nodes:
- are UI projections, not new payload schema entities
- have independent drag positions persisted in Zustand `auxNodePositions`
Semantic edge:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-semantic-edge.tsx`
Features:
- custom dashed smooth-step
- muted stroke styling
Legacy mixed edge component is removed (no `canvas-edge.tsx` path in active flow).
## 9.1) Derived aux nodes (LLM prompt/system/scorer)
Files:
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-aux-node.tsx`
- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx`
Behavior:
- If `llm.prompt` is non-empty, spawn `llm-prompt-input` aux node
- If `llm.system_prompt` is non-empty, spawn `llm-prompt-input` aux node
- For `llm_type === "judge"`, spawn one `llm-judge-score` aux node per `scores[index]`
- Aux edges auto-connect from aux `llmInputOut` to parent LLM target handles
- Aux nodes are draggable independently; positions persist in Zustand `auxNodePositions`
- Aux nodes are derived UI/editor projections only (no payload schema change)
## 10) Connection semantics + side effects
## 7) Connect Rules (single source of truth)
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts`
`.../utils/graph/canvas-connection.ts`
Lane rules:
- semantic lane only: `semantic-out -> semantic-in`
- data lane only: `data-out -> data-in`
- model infra nodes (`model_provider`, `model_config`) blocked from data lane
Rules:
- semantic lane only for model infra relations
- data lane for sampler/llm/expression flow
- model infra blocked from data lane
Semantic relations:
- `model_provider -> model_config`
- `model_config -> llm`
Connect side effects:
- provider edge sets `model_config.provider`
- model config edge sets `llm.model_alias`
- datetime edge sets `timedelta.reference_column_name`
- data edges into llm/expression append `{{ source_name }}` refs
- category -> subcategory syncs mapping scaffold
Single-incoming enforcement (competing refs pruned on connect):
Single incoming enforced for:
- `provider`
- `model_alias`
- `reference_column_name`
- `subcategory_parent`
Multi data refs remain allowed for llm/expression prompt/expr templates.
Connect side-effects:
- provider/model alias/ref-column update target config fields
- category->subcategory scaffolds mapping
- llm/expression data refs append template references
## 11) Canvas controls UX
## 8) Circular Dependency Prevention
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx`
Current safe flow:
- store state -> page (`configs`)
- page derives `modelConfigAliases`
- page passes aliases -> `ConfigDialog`
- dialog passes aliases -> registry -> `LlmDialog`
Top-right floating controls are icon-only:
- `+` opens add-block sheet
- import icon opens import dialog
- copy icon copies recipe (brief check icon state on success)
No dialog component should import store directly.
All use same no-bg bordered button style (`corner-squircle`, hover primary border/icon).
## 9) Add New Block (exact flow)
## 12) Dialog routing
1. Add config type:
`.../types/index.ts`
2. Add defaults + `nodeDataFromConfig` mapping:
`.../utils/index.ts`
3. Add registry definition:
`.../blocks/registry.tsx`
4. Add dialog component + wire via `renderDialog`:
`.../dialogs/...`
5. Choose UI mode policy:
`.../components/inline/inline-policy.ts`
6. If inline, add inline editor:
`.../components/inline/...`
7. Add connect semantics if needed:
`.../utils/graph/canvas-connection.ts`
8. Add payload mapping:
`.../utils/payload/...`
9. Add import parse/inference update:
`.../utils/import/...`
Config modal shell:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx`
## 10) Keep It Simple Rules
Routes block-specific forms through registry renderer:
`renderBlockDialog(config, categoryOptions, onUpdate)`
- Keep mutation logic in store/helpers only
- Keep graph legality/side-effects in graph utils only
- Keep view files free of business mutation branching
- Remove dead code in same pass as refactor
- Prefer narrow pure helpers over giant mixed functions
Current modal-only edits still live here (structured/judge/category/subcategory/etc).
## 13) Payload + import boundary
Payload map:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/build-payload.ts`
Import map:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts`
If `ui.edges` missing on import, inferred edges are built from config refs in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts`
## 14) Add new block checklist
1. Add/extend config type in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts`
2. Add factory + `nodeDataFromConfig` mapping in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/index.ts`
3. Add registry entry in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx`
4. Add dialog and wire in registry `renderDialog`
5. Decide inline vs dialog mode; update:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts`
6. If inline, add inline editor component under:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/`
7. Add payload mapping/validation updates in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/`
8. Add import parse/infer updates in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/`
9. Extend connection semantics if needed in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts`
## 15) Mental model
- `NodeConfig` = business truth
- `CanvasNodeData` = derived presentational truth
- registry = block metadata + factories + dialog routing
- store = orchestration + consistency
- graph utils = legal edges + side effects
- payload/import = external contract boundary
Keep all 6 synchronized when adding/changing block behavior.
If unsure where code belongs:
- “changes config/edges?” => store/helpers or graph utils
- “changes visuals only?” => components/page
- “changes payload contract?” => payload/import utils

View file

@ -58,6 +58,9 @@ type BlockGroup = {
type BlockDialogArgs = {
config: NodeConfig;
categoryOptions: SamplerConfig[];
modelConfigAliases: string[];
modelProviderOptions: string[];
datetimeOptions: string[];
onUpdate: (id: string, patch: Partial<NodeConfig>) => void;
};
@ -195,10 +198,11 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
icon: Clock01Icon,
createConfig: (id, existing) =>
makeSamplerConfig(id, "timedelta", existing),
renderDialog: ({ config, onUpdate }) =>
renderDialog: ({ config, datetimeOptions, onUpdate }) =>
config.kind === "sampler" && config.sampler_type === "timedelta" ? (
<TimedeltaDialog
config={config}
datetimeOptions={datetimeOptions}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
@ -242,10 +246,11 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
description: "Free-form prompt generation.",
icon: PencilEdit02Icon,
createConfig: (id, existing) => makeLlmConfig(id, "text", existing),
renderDialog: ({ config, onUpdate }) =>
renderDialog: ({ config, modelConfigAliases, onUpdate }) =>
config.kind === "llm" && config.llm_type === "text" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
@ -257,10 +262,11 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
description: "JSON output via schema.",
icon: CodeIcon,
createConfig: (id, existing) => makeLlmConfig(id, "structured", existing),
renderDialog: ({ config, onUpdate }) =>
renderDialog: ({ config, modelConfigAliases, onUpdate }) =>
config.kind === "llm" && config.llm_type === "structured" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
@ -272,10 +278,11 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
description: "Generate code or SQL.",
icon: CodeSimpleIcon,
createConfig: (id, existing) => makeLlmConfig(id, "code", existing),
renderDialog: ({ config, onUpdate }) =>
renderDialog: ({ config, modelConfigAliases, onUpdate }) =>
config.kind === "llm" && config.llm_type === "code" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
@ -287,10 +294,11 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
description: "Score outputs with criteria.",
icon: BalanceScaleIcon,
createConfig: (id, existing) => makeLlmConfig(id, "judge", existing),
renderDialog: ({ config, onUpdate }) =>
renderDialog: ({ config, modelConfigAliases, onUpdate }) =>
config.kind === "llm" && config.llm_type === "judge" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
@ -317,10 +325,11 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
description: "Alias + model + inference params.",
icon: Plant01Icon,
createConfig: (id, existing) => makeModelConfig(id, existing),
renderDialog: ({ config, onUpdate }) =>
renderDialog: ({ config, modelProviderOptions, onUpdate }) =>
config.kind === "model_config" ? (
<ModelConfigDialog
config={config}
providerOptions={modelProviderOptions}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
@ -385,11 +394,21 @@ export function getBlockDefinitionForConfig(
export function renderBlockDialog(
config: NodeConfig | null,
categoryOptions: SamplerConfig[],
modelConfigAliases: string[],
modelProviderOptions: string[],
datetimeOptions: string[],
onUpdate: (id: string, patch: Partial<NodeConfig>) => void,
): ReactElement | null {
const definition = getBlockDefinitionForConfig(config);
if (!definition || !config) {
return null;
}
return definition.renderDialog({ config, categoryOptions, onUpdate });
return definition.renderDialog({
config,
categoryOptions,
modelConfigAliases,
modelProviderOptions,
datetimeOptions,
onUpdate,
});
}

View file

@ -8,7 +8,6 @@ import {
type Node,
type NodeChange,
type NodeTypes,
type XYPosition,
Panel,
ReactFlow,
useReactFlow,
@ -43,7 +42,7 @@ import type {
SamplerConfig,
} from "./types";
import { isCategoryConfig } from "./utils";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "./utils/handles";
import { deriveDisplayGraph } from "./utils/graph/derive-display-graph";
import { importCanvasPayload } from "./utils/import";
import { buildCanvasPayload } from "./utils/payload";
import { buildDefaultSchemaTransform } from "./utils/processors";
@ -210,125 +209,13 @@ export function CanvasLabPage(): ReactElement {
);
const displayGraph = useMemo(() => {
const auxNodes: Node<CanvasAuxNodeData>[] = [];
const auxEdges: Edge[] = [];
const auxDefaults: Record<string, XYPosition> = {};
const auxNodeIds: string[] = [];
for (const node of nodes) {
const config = configs[node.id];
if (!(config && config.kind === "llm")) {
continue;
}
const llmDirection = node.data.layoutDirection ?? layoutDirection;
const items: Array<{
key: string;
targetHandle: string;
data: CanvasAuxNodeData;
}> = [];
if (config.system_prompt.trim()) {
items.push({
key: "system",
targetHandle: HANDLE_IDS.llmSystemIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "system_prompt",
title: "System Prompt",
layoutDirection: llmDirection,
},
});
}
if (config.prompt.trim()) {
items.push({
key: "prompt",
targetHandle: HANDLE_IDS.llmPromptIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "prompt",
title: "Prompt",
layoutDirection: llmDirection,
},
});
}
if (config.llm_type === "judge") {
(config.scores ?? []).forEach((_score, scoreIndex) => {
items.push({
key: `score-${scoreIndex}`,
targetHandle: getLlmJudgeScoreHandleId(scoreIndex),
data: {
kind: "llm-judge-score",
llmId: config.id,
scoreIndex,
layoutDirection: llmDirection,
},
});
});
}
if (items.length === 0) {
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 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;
auxNodeIds.push(auxId);
if (!auxNodePositions[auxId]) {
auxDefaults[auxId] = defaultPosition;
}
auxNodes.push({
id: auxId,
type: "aux",
data: item.data,
position,
draggable: true,
selectable: true,
focusable: true,
connectable: false,
});
auxEdges.push({
id: `e-${auxId}-${node.id}`,
source: auxId,
sourceHandle: HANDLE_IDS.llmInputOut,
target: node.id,
targetHandle: item.targetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
style: { strokeWidth: 1.5, stroke: "var(--border)" },
});
});
}
return {
nodes: [...nodes, ...auxNodes],
edges: [...edges, ...auxEdges],
auxNodeIds,
auxDefaults,
};
return deriveDisplayGraph({
nodes,
edges,
configs,
layoutDirection,
auxNodePositions,
});
}, [auxNodePositions, configs, edges, layoutDirection, nodes]);
const displayNodeIds = useMemo(
() => displayGraph.nodes.map((node) => node.id),
@ -390,6 +277,29 @@ export function CanvasLabPage(): ReactElement {
() => Object.values(configs).filter(isCategoryConfig),
[configs],
);
const modelConfigAliases = useMemo<string[]>(
() =>
Object.values(configs)
.filter((item) => item.kind === "model_config")
.map((item) => item.name),
[configs],
);
const modelProviderOptions = useMemo<string[]>(
() =>
Object.values(configs)
.filter((item) => item.kind === "model_provider")
.map((item) => item.name),
[configs],
);
const datetimeOptions = useMemo<string[]>(
() =>
Object.values(configs)
.filter(
(item) => item.kind === "sampler" && item.sampler_type === "datetime",
)
.map((item) => item.name),
[configs],
);
const handleToggleDirection = useCallback(() => {
setLayoutDirection(layoutDirection === "LR" ? "TB" : "LR");
@ -589,6 +499,9 @@ export function CanvasLabPage(): ReactElement {
onOpenChange={setDialogOpen}
config={config}
categoryOptions={categoryOptions}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
datetimeOptions={datetimeOptions}
onUpdate={updateConfig}
container={sheetContainer}
/>

View file

@ -2,8 +2,6 @@ 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",
@ -11,40 +9,6 @@ const INLINE_SAMPLERS = new Set<SamplerType>([
"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"],
llm_code: ["model_alias", "code_lang"],
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 {

View file

@ -12,6 +12,9 @@ type ConfigDialogProps = {
onOpenChange: (open: boolean) => void;
config: NodeConfig | null;
categoryOptions: SamplerConfig[];
modelConfigAliases: string[];
modelProviderOptions: string[];
datetimeOptions: string[];
onUpdate: (id: string, patch: Partial<NodeConfig>) => void;
container?: HTMLDivElement | null;
};
@ -21,6 +24,9 @@ export function ConfigDialog({
onOpenChange,
config,
categoryOptions,
modelConfigAliases,
modelProviderOptions,
datetimeOptions,
onUpdate,
container,
}: ConfigDialogProps): ReactElement {
@ -58,7 +64,14 @@ export function ConfigDialog({
/>
</div>
)}
{renderBlockDialog(config, categoryOptions, onUpdate)}
{renderBlockDialog(
config,
categoryOptions,
modelConfigAliases,
modelProviderOptions,
datetimeOptions,
onUpdate,
)}
</div>
)}
<DialogFooter>

View file

@ -15,8 +15,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, useMemo, useRef } from "react";
import { useCanvasLabStore } from "../../stores/canvas-lab";
import { type ReactElement, useRef } from "react";
import type { LlmConfig, Score } from "../../types";
import { NameField } from "../shared/name-field";
@ -41,18 +40,15 @@ const CODE_LANG_OPTIONS = [
type LlmDialogProps = {
config: LlmConfig;
modelConfigAliases: string[];
onUpdate: (patch: Partial<LlmConfig>) => void;
};
export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
const configs = useCanvasLabStore((state) => state.configs);
const modelConfigAliases = useMemo(
() =>
Object.values(configs)
.filter((item) => item.kind === "model_config")
.map((item) => item.name),
[configs],
);
export function LlmDialog({
config,
modelConfigAliases,
onUpdate,
}: LlmDialogProps): ReactElement {
const modelAliasId = `${config.id}-model-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;

View file

@ -8,28 +8,21 @@ import {
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { type ReactElement, useMemo, useRef } from "react";
import { type ReactElement, useRef } from "react";
import type { ModelConfig } from "../../types";
import { useCanvasLabStore } from "../../stores/canvas-lab";
import { NameField } from "../shared/name-field";
type ModelConfigDialogProps = {
config: ModelConfig;
providerOptions: string[];
onUpdate: (patch: Partial<ModelConfig>) => void;
};
export function ModelConfigDialog({
config,
providerOptions,
onUpdate,
}: ModelConfigDialogProps): ReactElement {
const configs = useCanvasLabStore((state) => state.configs);
const providerOptions = useMemo(
() =>
Object.values(configs)
.filter((item) => item.kind === "model_provider")
.map((item) => item.name),
[configs],
);
const modelId = `${config.id}-model`;
const providerId = `${config.id}-provider`;
const tempId = `${config.id}-temperature`;

View file

@ -6,8 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { type ReactElement, useMemo } from "react";
import { useCanvasLabStore } from "../../stores/canvas-lab";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
@ -16,23 +15,15 @@ const NONE_VALUE = "__none";
type TimedeltaDialogProps = {
config: SamplerConfig;
datetimeOptions: string[];
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function TimedeltaDialog({
config,
datetimeOptions,
onUpdate,
}: TimedeltaDialogProps): ReactElement {
const configs = useCanvasLabStore((state) => state.configs);
const datetimeOptions = useMemo(
() =>
Object.values(configs)
.filter(
(item) => item.kind === "sampler" && item.sampler_type === "datetime",
)
.map((item) => item.name),
[configs],
);
const dtMinId = `${config.id}-timedelta-min`;
const dtMaxId = `${config.id}-timedelta-max`;
const unitId = `${config.id}-timedelta-unit`;

View file

@ -1,3 +1,4 @@
import { type Edge, addEdge } from "@xyflow/react";
import type {
CanvasNode,
LayoutDirection,
@ -6,7 +7,8 @@ import type {
NodeConfig,
SamplerConfig,
} from "../types";
import { nodeDataFromConfig } from "../utils";
import { isCategoryConfig, isSubcategoryConfig, nodeDataFromConfig } from "../utils";
import { HANDLE_IDS } from "../utils/handles";
import { removeRef, replaceRef } from "../utils/refs";
import { getConfigUiMode } from "../components/inline/inline-policy";
@ -39,7 +41,7 @@ export function updateNodeData(
);
}
export function findNodeIdByName(
function findNodeIdByName(
configs: Record<string, NodeConfig>,
name: string,
): string | null {
@ -89,6 +91,188 @@ export function applyLayoutDirectionToNodes(
});
}
export function syncEdgesForConfigPatch(
current: NodeConfig,
patch: Partial<NodeConfig>,
configs: Record<string, NodeConfig>,
edges: Edge[],
): Edge[] {
let nextEdges = edges;
const hasParentPatch = Object.prototype.hasOwnProperty.call(
patch,
"subcategory_parent",
);
if (isSubcategoryConfig(current) && hasParentPatch) {
const nextParent = (patch as Partial<SamplerConfig>).subcategory_parent ?? "";
const parentId = nextParent ? findNodeIdByName(configs, nextParent) : null;
nextEdges = nextEdges.filter((edge) => edge.target !== current.id);
if (parentId) {
nextEdges = addEdge(
{
source: parentId,
target: current.id,
sourceHandle: HANDLE_IDS.dataOut,
targetHandle: HANDLE_IDS.dataIn,
type: "canvas",
},
nextEdges,
);
}
}
const hasProviderPatch = Object.prototype.hasOwnProperty.call(
patch,
"provider",
);
if (current.kind === "model_config" && hasProviderPatch) {
const nextProvider = (patch as Partial<ModelConfig>).provider ?? "";
nextEdges = nextEdges.filter((edge) => {
if (edge.target !== current.id) {
return true;
}
const source = configs[edge.source];
return !(source && source.kind === "model_provider");
});
if (nextProvider) {
const providerId = findNodeIdByName(configs, nextProvider);
if (providerId) {
nextEdges = addEdge(
{
source: providerId,
target: current.id,
sourceHandle: HANDLE_IDS.semanticOut,
targetHandle: HANDLE_IDS.semanticIn,
type: "semantic",
},
nextEdges,
);
}
}
}
const hasReferencePatch = Object.prototype.hasOwnProperty.call(
patch,
"reference_column_name",
);
if (
current.kind === "sampler" &&
current.sampler_type === "timedelta" &&
hasReferencePatch
) {
const nextReference =
(patch as Partial<SamplerConfig>).reference_column_name ?? "";
nextEdges = nextEdges.filter((edge) => {
if (edge.target !== current.id) {
return true;
}
const source = configs[edge.source];
return !(
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime"
);
});
if (nextReference) {
const referenceId = findNodeIdByName(configs, nextReference);
const source = referenceId ? configs[referenceId] : null;
if (
referenceId &&
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime"
) {
nextEdges = addEdge(
{
source: referenceId,
target: current.id,
sourceHandle: HANDLE_IDS.dataOut,
targetHandle: HANDLE_IDS.dataIn,
type: "canvas",
},
nextEdges,
);
}
}
}
const hasModelAliasPatch = Object.prototype.hasOwnProperty.call(
patch,
"model_alias",
);
if (current.kind === "llm" && hasModelAliasPatch) {
const nextAlias =
(patch as Partial<NodeConfig> & { model_alias?: string }).model_alias ?? "";
nextEdges = nextEdges.filter((edge) => {
if (edge.target !== current.id) {
return true;
}
const source = configs[edge.source];
return !(source && source.kind === "model_config");
});
if (nextAlias) {
const modelConfigId = findNodeIdByName(configs, nextAlias);
if (modelConfigId) {
nextEdges = addEdge(
{
source: modelConfigId,
target: current.id,
sourceHandle: HANDLE_IDS.semanticOut,
targetHandle: HANDLE_IDS.semanticIn,
type: "semantic",
},
nextEdges,
);
}
}
}
return nextEdges;
}
export function syncSubcategoryConfigsForCategoryUpdate(
current: NodeConfig,
next: NodeConfig,
configs: Record<string, NodeConfig>,
oldName: string,
newName: string,
nameChanged: boolean,
): Record<string, NodeConfig> {
if (!isCategoryConfig(current)) {
return configs;
}
const nextCategory = isCategoryConfig(next) ? next : current;
const oldValues = current.values ?? [];
const newValues = nextCategory.values ?? [];
const valuesChanged =
oldValues.length !== newValues.length ||
oldValues.some((value, index) => value !== newValues[index]);
let nextConfigs = configs;
for (const config of Object.values(configs)) {
if (!isSubcategoryConfig(config)) {
continue;
}
if (config.subcategory_parent !== oldName) {
continue;
}
const mapping = config.subcategory_mapping ?? {};
const nextMapping: Record<string, string[]> = {};
for (const value of newValues) {
nextMapping[value] = mapping[value] ?? [];
}
const updated: NodeConfig = {
...config,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: nameChanged ? newName : config.subcategory_parent,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: valuesChanged ? nextMapping : mapping,
};
nextConfigs = { ...nextConfigs, [config.id]: updated };
}
return nextConfigs;
}
function updateTemplateFields(
config: NodeConfig,
updater: (value: string) => string,

View file

@ -5,7 +5,6 @@ import {
type IsValidConnection,
type NodeChange,
type XYPosition,
addEdge,
applyEdgeChanges,
applyNodeChanges,
} from "@xyflow/react";
@ -15,15 +14,12 @@ import type {
CanvasProcessorConfig,
LayoutDirection,
LlmType,
ModelConfig,
NodeConfig,
SamplerConfig,
SamplerType,
} from "../types";
import { getBlockDefinition } from "../blocks/registry";
import { isCategoryConfig, isSubcategoryConfig } from "../utils";
import { applyCanvasConnection, isValidCanvasConnection } from "../utils/graph";
import { HANDLE_IDS } from "../utils/handles";
import type { CanvasSnapshot } from "../utils/import";
import { getLayoutedElements } from "../utils/layout";
import {
@ -32,7 +28,8 @@ import {
applyRenameToConfigs,
applyLayoutDirectionToNodes,
buildNodeUpdate,
findNodeIdByName,
syncEdgesForConfigPatch,
syncSubcategoryConfigsForCategoryUpdate,
updateNodeData,
} from "./canvas-lab-helpers";
@ -236,7 +233,6 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
return state;
}),
updateConfig: (id, patch) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyUpdate = (state: CanvasLabState) => {
const current = state.configs[id];
if (!current) {
@ -256,171 +252,15 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
next,
state.layoutDirection,
);
let edges = state.edges;
const hasParentPatch = Object.prototype.hasOwnProperty.call(
patch,
"subcategory_parent",
const edges = syncEdgesForConfigPatch(current, patch, configs, state.edges);
configs = syncSubcategoryConfigsForCategoryUpdate(
current,
next,
configs,
oldName,
newName,
nameChanged,
);
if (isSubcategoryConfig(current) && hasParentPatch) {
const nextParent =
(patch as Partial<SamplerConfig>).subcategory_parent ?? "";
const parentId = nextParent
? findNodeIdByName(configs, nextParent)
: null;
edges = edges.filter((edge) => edge.target !== id);
if (parentId) {
edges = addEdge(
{
source: parentId,
target: id,
sourceHandle: HANDLE_IDS.dataOut,
targetHandle: HANDLE_IDS.dataIn,
type: "canvas",
},
edges,
);
}
}
const hasProviderPatch = Object.prototype.hasOwnProperty.call(
patch,
"provider",
);
if (current.kind === "model_config" && hasProviderPatch) {
const nextProvider = (patch as Partial<ModelConfig>).provider ?? "";
edges = edges.filter((edge) => {
if (edge.target !== id) {
return true;
}
const source = configs[edge.source];
return !(source && source.kind === "model_provider");
});
if (nextProvider) {
const providerId = findNodeIdByName(configs, nextProvider);
if (providerId) {
edges = addEdge(
{
source: providerId,
target: id,
sourceHandle: HANDLE_IDS.semanticOut,
targetHandle: HANDLE_IDS.semanticIn,
type: "semantic",
},
edges,
);
}
}
}
const hasReferencePatch = Object.prototype.hasOwnProperty.call(
patch,
"reference_column_name",
);
if (
current.kind === "sampler" &&
current.sampler_type === "timedelta" &&
hasReferencePatch
) {
const nextReference =
(patch as Partial<SamplerConfig>).reference_column_name ?? "";
edges = edges.filter((edge) => {
if (edge.target !== id) {
return true;
}
const source = configs[edge.source];
return !(
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime"
);
});
if (nextReference) {
const referenceId = findNodeIdByName(configs, nextReference);
const source = referenceId ? configs[referenceId] : null;
if (
referenceId &&
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime"
) {
edges = addEdge(
{
source: referenceId,
target: id,
sourceHandle: HANDLE_IDS.dataOut,
targetHandle: HANDLE_IDS.dataIn,
type: "canvas",
},
edges,
);
}
}
}
const hasModelAliasPatch = Object.prototype.hasOwnProperty.call(
patch,
"model_alias",
);
if (current.kind === "llm" && hasModelAliasPatch) {
const nextAlias =
(patch as Partial<NodeConfig> & { model_alias?: string }).model_alias ?? "";
edges = edges.filter((edge) => {
if (edge.target !== id) {
return true;
}
const source = configs[edge.source];
return !(source && source.kind === "model_config");
});
if (nextAlias) {
const modelConfigId = findNodeIdByName(configs, nextAlias);
if (modelConfigId) {
edges = addEdge(
{
source: modelConfigId,
target: id,
sourceHandle: HANDLE_IDS.semanticOut,
targetHandle: HANDLE_IDS.semanticIn,
type: "semantic",
},
edges,
);
}
}
}
if (isCategoryConfig(current)) {
const nextCategory = isCategoryConfig(next) ? next : current;
const oldValues = current.values ?? [];
const newValues = nextCategory.values ?? [];
const valuesChanged =
oldValues.length !== newValues.length ||
oldValues.some((value, index) => value !== newValues[index]);
for (const config of Object.values(configs)) {
if (!isSubcategoryConfig(config)) {
continue;
}
if (config.subcategory_parent !== oldName) {
continue;
}
const mapping = config.subcategory_mapping ?? {};
const nextMapping: Record<string, string[]> = {};
for (const value of newValues) {
nextMapping[value] = mapping[value] ?? [];
}
const updated: NodeConfig = {
...config,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: nameChanged
? newName
: config.subcategory_parent,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: valuesChanged ? nextMapping : mapping,
};
configs = { ...configs, [config.id]: updated };
}
}
if (nameChanged) {
configs = applyRenameToConfigs(configs, oldName, newName);

View file

@ -1,227 +1,4 @@
import { type Connection, type Edge, addEdge } from "@xyflow/react";
import type { NodeConfig, SamplerConfig } from "../types";
import { HANDLE_IDS } from "./handles";
import {
isCategoryConfig,
isExpressionConfig,
isLlmConfig,
isSubcategoryConfig,
} from "./index";
function buildTemplateWithRef(template: string, ref: string): string {
if (template.includes(ref)) {
return template;
}
if (template.trim()) {
return `${template}\n${ref}`;
}
return ref;
}
function syncSubcategoryMapping(
subcategory: SamplerConfig,
parent: NodeConfig,
): SamplerConfig {
if (!isCategoryConfig(parent)) {
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
};
}
const nextMapping: Record<string, string[]> = {
...(subcategory.subcategory_mapping ?? {}),
};
for (const value of parent.values ?? []) {
if (!nextMapping[value]) {
nextMapping[value] = [];
}
}
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: nextMapping,
};
}
function isSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
if (source.kind === "model_provider" && target.kind === "model_config") {
return true;
}
return source.kind === "model_config" && target.kind === "llm";
}
function isModelInfraNode(config: NodeConfig): boolean {
return config.kind === "model_provider" || config.kind === "model_config";
}
function isSemanticLane(connection: Connection): boolean {
return (
connection.sourceHandle === HANDLE_IDS.semanticOut &&
connection.targetHandle === HANDLE_IDS.semanticIn
);
}
function isDataLane(connection: Connection): boolean {
return (
connection.sourceHandle === HANDLE_IDS.dataOut &&
connection.targetHandle === HANDLE_IDS.dataIn
);
}
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>,
): boolean {
if (!(connection.source && connection.target)) {
return false;
}
if (connection.source === connection.target) {
return false;
}
const source = configs[connection.source];
const target = configs[connection.target];
if (!(source && target)) {
return false;
}
const semanticRelation = isSemanticRelation(source, target);
if (semanticRelation) {
return isSemanticLane(connection);
}
if (isModelInfraNode(source) || isModelInfraNode(target)) {
return false;
}
return isDataLane(connection);
}
export function applyCanvasConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
edges: Edge[],
): { edges: Edge[]; configs?: Record<string, NodeConfig> } {
if (!isValidCanvasConnection(connection, configs)) {
return { edges };
}
const source = connection.source ? configs[connection.source] : null;
const target = connection.target ? configs[connection.target] : null;
if (!(source && target)) {
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" },
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
const next = { ...target, provider: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (source.kind === "model_config" && target.kind === "llm") {
const next = { ...target, model_alias: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (
source.kind === "sampler" &&
source.sampler_type === "datetime" &&
target.kind === "sampler" &&
target.sampler_type === "timedelta"
) {
const next = {
...target,
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: source.name,
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isLlmConfig(target) && source.kind !== "model_provider" && source.kind !== "model_config") {
const ref = `{{ ${source.name} }}`;
const next = {
...target,
prompt: buildTemplateWithRef(target.prompt ?? "", ref),
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isExpressionConfig(target) && source.kind !== "model_provider" && source.kind !== "model_config") {
const ref = `{{ ${source.name} }}`;
const next = {
...target,
expr: buildTemplateWithRef(target.expr ?? "", ref),
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isSubcategoryConfig(target) && isCategoryConfig(source)) {
const next = syncSubcategoryMapping(target, source);
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
return { edges: nextEdges };
}
export {
applyCanvasConnection,
isValidCanvasConnection,
} from "./graph/canvas-connection";

View file

@ -0,0 +1,229 @@
import { type Connection, type Edge, addEdge } from "@xyflow/react";
import type { NodeConfig, SamplerConfig } from "../../types";
import { HANDLE_IDS } from "../handles";
import {
isCategoryConfig,
isExpressionConfig,
isLlmConfig,
isSubcategoryConfig,
} from "../index";
function buildTemplateWithRef(template: string, ref: string): string {
if (template.includes(ref)) {
return template;
}
if (template.trim()) {
return `${template}\n${ref}`;
}
return ref;
}
function syncSubcategoryMapping(
subcategory: SamplerConfig,
parent: NodeConfig,
): SamplerConfig {
if (!isCategoryConfig(parent)) {
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
};
}
const nextMapping: Record<string, string[]> = {
...(subcategory.subcategory_mapping ?? {}),
};
for (const value of parent.values ?? []) {
if (!nextMapping[value]) {
nextMapping[value] = [];
}
}
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: nextMapping,
};
}
function isSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
if (source.kind === "model_provider" && target.kind === "model_config") {
return true;
}
return source.kind === "model_config" && target.kind === "llm";
}
function isModelInfraNode(config: NodeConfig): boolean {
return config.kind === "model_provider" || config.kind === "model_config";
}
function isSemanticLane(connection: Connection): boolean {
return (
connection.sourceHandle === HANDLE_IDS.semanticOut &&
connection.targetHandle === HANDLE_IDS.semanticIn
);
}
function isDataLane(connection: Connection): boolean {
return (
connection.sourceHandle === HANDLE_IDS.dataOut &&
connection.targetHandle === HANDLE_IDS.dataIn
);
}
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>,
): boolean {
if (!(connection.source && connection.target)) {
return false;
}
if (connection.source === connection.target) {
return false;
}
const source = configs[connection.source];
const target = configs[connection.target];
if (!(source && target)) {
return false;
}
const semanticRelation = isSemanticRelation(source, target);
if (semanticRelation) {
return isSemanticLane(connection);
}
if (isModelInfraNode(source) || isModelInfraNode(target)) {
return false;
}
return isDataLane(connection);
}
export function applyCanvasConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
edges: Edge[],
): { edges: Edge[]; configs?: Record<string, NodeConfig> } {
if (!isValidCanvasConnection(connection, configs)) {
return { edges };
}
const source = connection.source ? configs[connection.source] : null;
const target = connection.target ? configs[connection.target] : null;
if (!(source && target)) {
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" },
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
const next = { ...target, provider: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (source.kind === "model_config" && target.kind === "llm") {
const next = { ...target, model_alias: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (
source.kind === "sampler" &&
source.sampler_type === "datetime" &&
target.kind === "sampler" &&
target.sampler_type === "timedelta"
) {
const next = {
...target,
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: source.name,
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (
isLlmConfig(target) &&
source.kind !== "model_provider" &&
source.kind !== "model_config"
) {
const ref = `{{ ${source.name} }}`;
const next = {
...target,
prompt: buildTemplateWithRef(target.prompt ?? "", ref),
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (
isExpressionConfig(target) &&
source.kind !== "model_provider" &&
source.kind !== "model_config"
) {
const ref = `{{ ${source.name} }}`;
const next = {
...target,
expr: buildTemplateWithRef(target.expr ?? "", ref),
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isSubcategoryConfig(target) && isCategoryConfig(source)) {
const next = syncSubcategoryMapping(target, source);
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
return { edges: nextEdges };
}

View file

@ -0,0 +1,150 @@
import type { Edge, Node, XYPosition } from "@xyflow/react";
import type { CanvasAuxNodeData } from "../../components/canvas-aux-node";
import type { CanvasNode, LayoutDirection, NodeConfig } from "../../types";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../handles";
type DisplayGraphInput = {
nodes: CanvasNode[];
edges: Edge[];
configs: Record<string, NodeConfig>;
layoutDirection: LayoutDirection;
auxNodePositions: Record<string, XYPosition>;
};
export type DisplayGraph = {
nodes: Array<Node<CanvasNode["data"] | CanvasAuxNodeData>>;
edges: Edge[];
auxNodeIds: string[];
auxDefaults: Record<string, XYPosition>;
};
type AuxNodeItem = {
key: string;
targetHandle: string;
data: CanvasAuxNodeData;
};
export function deriveDisplayGraph({
nodes,
edges,
configs,
layoutDirection,
auxNodePositions,
}: DisplayGraphInput): DisplayGraph {
const auxNodes: Node<CanvasAuxNodeData>[] = [];
const auxEdges: Edge[] = [];
const auxDefaults: Record<string, XYPosition> = {};
const auxNodeIds: string[] = [];
for (const node of nodes) {
const config = configs[node.id];
if (!(config && config.kind === "llm")) {
continue;
}
const llmDirection = node.data.layoutDirection ?? layoutDirection;
const items: AuxNodeItem[] = [];
if (config.system_prompt.trim()) {
items.push({
key: "system",
targetHandle: HANDLE_IDS.llmSystemIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "system_prompt",
title: "System Prompt",
layoutDirection: llmDirection,
},
});
}
if (config.prompt.trim()) {
items.push({
key: "prompt",
targetHandle: HANDLE_IDS.llmPromptIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "prompt",
title: "Prompt",
layoutDirection: llmDirection,
},
});
}
if (config.llm_type === "judge") {
(config.scores ?? []).forEach((_score, scoreIndex) => {
items.push({
key: `score-${scoreIndex}`,
targetHandle: getLlmJudgeScoreHandleId(scoreIndex),
data: {
kind: "llm-judge-score",
llmId: config.id,
scoreIndex,
layoutDirection: llmDirection,
},
});
});
}
if (items.length === 0) {
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 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;
auxNodeIds.push(auxId);
if (!auxNodePositions[auxId]) {
auxDefaults[auxId] = defaultPosition;
}
auxNodes.push({
id: auxId,
type: "aux",
data: item.data,
position,
draggable: true,
selectable: true,
focusable: true,
connectable: false,
});
auxEdges.push({
id: `e-${auxId}-${node.id}`,
source: auxId,
sourceHandle: HANDLE_IDS.llmInputOut,
target: node.id,
targetHandle: item.targetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
style: { strokeWidth: 1.5, stroke: "var(--border)" },
});
});
}
return {
nodes: [...nodes, ...auxNodes],
edges: [...edges, ...auxEdges],
auxNodeIds,
auxDefaults,
};
}