cleanup and restctucture
This commit is contained in:
parent
0db132813b
commit
baa2a8426c
13 changed files with 852 additions and 827 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue