diff --git a/docs/canvas-lab-architecture.md b/docs/canvas-lab-architecture.md index 858b2bedc7..75749d6b1f 100644 --- a/docs/canvas-lab-architecture.md +++ b/docs/canvas-lab-architecture.md @@ -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 diff --git a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx index d9ab7fa022..695c152f10 100644 --- a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx +++ b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx @@ -58,6 +58,9 @@ type BlockGroup = { type BlockDialogArgs = { config: NodeConfig; categoryOptions: SamplerConfig[]; + modelConfigAliases: string[]; + modelProviderOptions: string[]; + datetimeOptions: string[]; onUpdate: (id: string, patch: Partial) => 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" ? ( 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" ? ( 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" ? ( 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" ? ( 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" ? ( 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" ? ( 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) => 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, + }); } diff --git a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx index 24a7247eab..cd2b5dc2dd 100644 --- a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx +++ b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx @@ -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[] = []; - const auxEdges: Edge[] = []; - const auxDefaults: Record = {}; - 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( + () => + Object.values(configs) + .filter((item) => item.kind === "model_config") + .map((item) => item.name), + [configs], + ); + const modelProviderOptions = useMemo( + () => + Object.values(configs) + .filter((item) => item.kind === "model_provider") + .map((item) => item.name), + [configs], + ); + const datetimeOptions = useMemo( + () => + 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} /> diff --git a/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts b/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts index 7f68ed21b6..97f441edce 100644 --- a/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts +++ b/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts @@ -2,8 +2,6 @@ import type { NodeConfig, SamplerType } from "../../types"; export type ConfigUiMode = "inline" | "dialog"; -type InlineFieldMap = Record; - const INLINE_SAMPLERS = new Set([ "uniform", "gaussian", @@ -11,40 +9,6 @@ const INLINE_SAMPLERS = new Set([ "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 { diff --git a/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx index f08f7d3cbe..acce6affe7 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx @@ -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) => 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({ /> )} - {renderBlockDialog(config, categoryOptions, onUpdate)} + {renderBlockDialog( + config, + categoryOptions, + modelConfigAliases, + modelProviderOptions, + datetimeOptions, + onUpdate, + )} )} diff --git a/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx index 2bd87cbc0e..34840b87a2 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx @@ -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) => 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`; diff --git a/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx index 899e79ee34..dad4063b2a 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx @@ -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) => 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`; diff --git a/studio/frontend/src/features/canvas-lab/dialogs/samplers/timedelta-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/samplers/timedelta-dialog.tsx index a636cec331..8abf5d8c35 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/samplers/timedelta-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/samplers/timedelta-dialog.tsx @@ -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) => 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`; diff --git a/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts b/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts index b8f61109d1..4551db0a43 100644 --- a/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts +++ b/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts @@ -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, name: string, ): string | null { @@ -89,6 +91,188 @@ export function applyLayoutDirectionToNodes( }); } +export function syncEdgesForConfigPatch( + current: NodeConfig, + patch: Partial, + configs: Record, + edges: Edge[], +): Edge[] { + let nextEdges = edges; + + const hasParentPatch = Object.prototype.hasOwnProperty.call( + patch, + "subcategory_parent", + ); + if (isSubcategoryConfig(current) && hasParentPatch) { + const nextParent = (patch as Partial).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).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).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 & { 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, + oldName: string, + newName: string, + nameChanged: boolean, +): Record { + 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 = {}; + 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, diff --git a/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts b/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts index 5a5fe1123d..0b156ca3a9 100644 --- a/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts +++ b/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts @@ -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((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((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).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).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).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 & { 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 = {}; - 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); diff --git a/studio/frontend/src/features/canvas-lab/utils/graph.ts b/studio/frontend/src/features/canvas-lab/utils/graph.ts index 7a47880d4a..66b2454701 100644 --- a/studio/frontend/src/features/canvas-lab/utils/graph.ts +++ b/studio/frontend/src/features/canvas-lab/utils/graph.ts @@ -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 = { - ...(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, -): 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, -): 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, - edges: Edge[], -): { edges: Edge[]; configs?: Record } { - 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"; diff --git a/studio/frontend/src/features/canvas-lab/utils/graph/canvas-connection.ts b/studio/frontend/src/features/canvas-lab/utils/graph/canvas-connection.ts new file mode 100644 index 0000000000..eefe370e98 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/utils/graph/canvas-connection.ts @@ -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 = { + ...(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, +): 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, +): 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, + edges: Edge[], +): { edges: Edge[]; configs?: Record } { + 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 }; +} diff --git a/studio/frontend/src/features/canvas-lab/utils/graph/derive-display-graph.ts b/studio/frontend/src/features/canvas-lab/utils/graph/derive-display-graph.ts new file mode 100644 index 0000000000..df88825b62 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/utils/graph/derive-display-graph.ts @@ -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; + layoutDirection: LayoutDirection; + auxNodePositions: Record; +}; + +export type DisplayGraph = { + nodes: Array>; + edges: Edge[]; + auxNodeIds: string[]; + auxDefaults: Record; +}; + +type AuxNodeItem = { + key: string; + targetHandle: string; + data: CanvasAuxNodeData; +}; + +export function deriveDisplayGraph({ + nodes, + edges, + configs, + layoutDirection, + auxNodePositions, +}: DisplayGraphInput): DisplayGraph { + const auxNodes: Node[] = []; + const auxEdges: Edge[] = []; + const auxDefaults: Record = {}; + 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, + }; +}