From d29643dbb61a0eb76214548a48c3e508246d3d8e Mon Sep 17 00:00:00 2001 From: shine1i Date: Wed, 4 Feb 2026 15:42:46 +0100 Subject: [PATCH] refactor: centralize block definitions and dialogs into registry, streamline node updates using helper utilities --- .../features/canvas-lab/blocks/registry.tsx | 283 ++++++++++++++++++ .../canvas-lab/components/block-sheet.tsx | 160 ++-------- .../canvas-lab/dialogs/config-dialog.tsx | 70 +---- .../canvas-lab/stores/canvas-lab-helpers.ts | 196 ++++++++++++ .../features/canvas-lab/stores/canvas-lab.ts | 146 +++++---- .../canvas-lab/utils/import/helpers.ts | 11 +- .../src/features/canvas-lab/utils/index.ts | 35 ++- .../src/features/canvas-lab/utils/refs.ts | 49 +++ 8 files changed, 640 insertions(+), 310 deletions(-) create mode 100644 studio/frontend/src/features/canvas-lab/blocks/registry.tsx create mode 100644 studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts create mode 100644 studio/frontend/src/features/canvas-lab/utils/refs.ts diff --git a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx new file mode 100644 index 0000000000..cf0cc82096 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx @@ -0,0 +1,283 @@ +import { + CodeIcon, + Database02Icon, + Flowchart01Icon, + SparklesIcon, +} from "@hugeicons/core-free-icons"; +import type { ReactElement } from "react"; +import type { LlmType, NodeConfig, SamplerConfig, SamplerType } from "../types"; +import { + makeExpressionConfig, + makeLlmConfig, + makeSamplerConfig, +} from "../utils"; +import { ExpressionDialog } from "../dialogs/expression/expression-dialog"; +import { LlmDialog } from "../dialogs/llm/llm-dialog"; +import { CategoryDialog } from "../dialogs/samplers/category-dialog"; +import { DatetimeDialog } from "../dialogs/samplers/datetime-dialog"; +import { GaussianDialog } from "../dialogs/samplers/gaussian-dialog"; +import { PersonDialog } from "../dialogs/samplers/person-dialog"; +import { SubcategoryDialog } from "../dialogs/samplers/subcategory-dialog"; +import { UniformDialog } from "../dialogs/samplers/uniform-dialog"; +import { UuidDialog } from "../dialogs/samplers/uuid-dialog"; + +export type BlockKind = "sampler" | "llm" | "expression"; +export type BlockType = SamplerType | LlmType | "expression"; + +type IconType = typeof Database02Icon; + +type BlockGroup = { + kind: BlockKind; + title: string; + description: string; + icon: IconType; +}; + +type BlockDialogArgs = { + config: NodeConfig; + categoryOptions: SamplerConfig[]; + onUpdate: (id: string, patch: Partial) => void; +}; + +type BlockDefinition = { + kind: BlockKind; + type: BlockType; + title: string; + description: string; + icon: IconType; + createConfig: (id: string, existing: NodeConfig[]) => NodeConfig; + renderDialog: (args: BlockDialogArgs) => ReactElement | null; +}; + +export const BLOCK_GROUPS: BlockGroup[] = [ + { + kind: "sampler", + title: "Sampler", + description: "Numeric + categorical blocks.", + icon: Database02Icon, + }, + { + kind: "llm", + title: "LLM", + description: "Text + structured blocks.", + icon: SparklesIcon, + }, + { + kind: "expression", + title: "Expression", + description: "Derived columns with Jinja.", + icon: CodeIcon, + }, +]; + +const BLOCK_DEFINITIONS: BlockDefinition[] = [ + { + kind: "sampler", + type: "category", + title: "Category", + description: "Pick from a list of values.", + icon: Database02Icon, + createConfig: (id, existing) => makeSamplerConfig(id, "category", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "category" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "sampler", + type: "subcategory", + title: "Subcategory", + description: "Map sub-values to a category.", + icon: Database02Icon, + createConfig: (id, existing) => + makeSamplerConfig(id, "subcategory", existing), + renderDialog: ({ config, categoryOptions, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "subcategory" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "sampler", + type: "uniform", + title: "Uniform", + description: "Random number between low/high.", + icon: Database02Icon, + createConfig: (id, existing) => makeSamplerConfig(id, "uniform", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "uniform" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "sampler", + type: "gaussian", + title: "Gaussian", + description: "Normal distribution sampler.", + icon: Database02Icon, + createConfig: (id, existing) => makeSamplerConfig(id, "gaussian", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "gaussian" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "sampler", + type: "datetime", + title: "Datetime", + description: "Date/time range sampler.", + icon: Database02Icon, + createConfig: (id, existing) => makeSamplerConfig(id, "datetime", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "datetime" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "sampler", + type: "uuid", + title: "UUID", + description: "UUID string sampler.", + icon: Database02Icon, + createConfig: (id, existing) => makeSamplerConfig(id, "uuid", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "uuid" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "sampler", + type: "person", + title: "Person", + description: "Synthetic person sampler.", + icon: Database02Icon, + createConfig: (id, existing) => makeSamplerConfig(id, "person", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "sampler" && config.sampler_type === "person" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "llm", + type: "text", + title: "LLM Text", + description: "Free-form prompt generation.", + icon: SparklesIcon, + createConfig: (id, existing) => makeLlmConfig(id, "text", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "llm" && config.llm_type === "text" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "llm", + type: "structured", + title: "LLM Structured", + description: "JSON output via schema.", + icon: Flowchart01Icon, + createConfig: (id, existing) => makeLlmConfig(id, "structured", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "llm" && config.llm_type === "structured" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "llm", + type: "code", + title: "LLM Code", + description: "Generate code or SQL.", + icon: CodeIcon, + createConfig: (id, existing) => makeLlmConfig(id, "code", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "llm" && config.llm_type === "code" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, + { + kind: "expression", + type: "expression", + title: "Expression", + description: "Transform columns with Jinja.", + icon: CodeIcon, + createConfig: (id, existing) => makeExpressionConfig(id, existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "expression" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, +]; + +export function getBlocksForKind(kind: BlockKind): BlockDefinition[] { + return BLOCK_DEFINITIONS.filter((block) => block.kind === kind); +} + +export function getBlockDefinition( + kind: BlockKind, + type: BlockType, +): BlockDefinition | null { + return ( + BLOCK_DEFINITIONS.find( + (block) => block.kind === kind && block.type === type, + ) ?? null + ); +} + +export function getBlockDefinitionForConfig( + config: NodeConfig | null, +): BlockDefinition | null { + if (!config) { + return null; + } + if (config.kind === "sampler") { + return getBlockDefinition("sampler", config.sampler_type); + } + if (config.kind === "llm") { + return getBlockDefinition("llm", config.llm_type); + } + return getBlockDefinition("expression", "expression"); +} + +export function renderBlockDialog( + config: NodeConfig | null, + categoryOptions: SamplerConfig[], + onUpdate: (id: string, patch: Partial) => void, +): ReactElement | null { + const definition = getBlockDefinitionForConfig(config); + if (!definition || !config) { + return null; + } + return definition.renderDialog({ config, categoryOptions, onUpdate }); +} diff --git a/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx b/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx index 2b7cacd624..46a990722f 100644 --- a/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx +++ b/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx @@ -8,16 +8,13 @@ import { } from "@/components/ui/sheet"; import { ArrowLeft02Icon, - ArrowRight01Icon, - CodeIcon, - Database02Icon, - Flowchart01Icon, + ArrowRight01Icon, type Database02Icon, PlusSignIcon, - SparklesIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import type { ReactElement } from "react"; import type { LlmType, SamplerType } from "../types"; +import { BLOCK_GROUPS, getBlocksForKind } from "../blocks/registry"; type SheetView = "root" | "sampler" | "llm" | "expression"; type SheetKind = "sampler" | "llm" | "expression"; @@ -44,115 +41,12 @@ function getSheetTitle(view: SheetView): string { return "LLM blocks"; } -const MAIN_SHEET_ITEMS: Array<{ - kind: SheetKind; - title: string; - description: string; - icon: typeof Database02Icon; -}> = [ - { - kind: "sampler", - title: "Sampler", - description: "Numeric + categorical blocks.", - icon: Database02Icon, - }, - { - kind: "llm", - title: "LLM", - description: "Text + structured blocks.", - icon: SparklesIcon, - }, - { - kind: "expression", - title: "Expression", - description: "Derived columns with Jinja.", - icon: CodeIcon, - }, -]; - -const SAMPLER_ITEMS = [ - { - type: "category" as const, - title: "Category", - description: "Pick from a list of values.", - icon: Database02Icon, - }, - { - type: "subcategory" as const, - title: "Subcategory", - description: "Map sub-values to a category.", - icon: Database02Icon, - }, - { - type: "uniform" as const, - title: "Uniform", - description: "Random number between low/high.", - icon: Database02Icon, - }, - { - type: "gaussian" as const, - title: "Gaussian", - description: "Normal distribution sampler.", - icon: Database02Icon, - }, - { - type: "datetime" as const, - title: "Datetime", - description: "Date/time range sampler.", - icon: Database02Icon, - }, - { - type: "uuid" as const, - title: "UUID", - description: "UUID string sampler.", - icon: Database02Icon, - }, - { - type: "person" as const, - title: "Person", - description: "Synthetic person sampler.", - icon: Database02Icon, - }, -]; - -const LLM_ITEMS = [ - { - type: "text" as const, - title: "LLM Text", - description: "Free-form prompt generation.", - icon: SparklesIcon, - }, - { - type: "structured" as const, - title: "LLM Structured", - description: "JSON output via schema.", - icon: Flowchart01Icon, - }, - { - type: "code" as const, - title: "LLM Code", - description: "Generate code or SQL.", - icon: CodeIcon, - }, -]; - -const EXPRESSION_ITEMS = [ - { - title: "Expression", - description: "Transform columns with Jinja.", - icon: CodeIcon, - }, -]; - -function nextViewForKind(kind: SheetKind): SheetView { - if (kind === "sampler") { - return "sampler"; - } - if (kind === "expression") { - return "expression"; - } - return "llm"; -} +const VIEW_KIND: Record = { + root: null, + sampler: "sampler", + llm: "llm", + expression: "expression", +}; function BlockSheetButton({ icon, @@ -228,43 +122,31 @@ export function BlockSheet({
{view === "root" && - MAIN_SHEET_ITEMS.map((item) => ( + BLOCK_GROUPS.map((item) => ( onViewChange(nextViewForKind(item.kind))} + onClick={() => onViewChange(item.kind)} /> ))} - {view === "sampler" && - SAMPLER_ITEMS.map((item) => ( + {view !== "root" && + getBlocksForKind(VIEW_KIND[view] ?? "sampler").map((item) => ( onAddSampler(item.type)} - /> - ))} - {view === "llm" && - LLM_ITEMS.map((item) => ( - onAddLlm(item.type)} - /> - ))} - {view === "expression" && - EXPRESSION_ITEMS.map((item) => ( - { + if (item.kind === "sampler") { + onAddSampler(item.type as SamplerType); + } else if (item.kind === "llm") { + onAddLlm(item.type as LlmType); + } else { + onAddExpression(); + } + }} /> ))}
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 f047f804a0..06ad08b188 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx @@ -2,17 +2,9 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog"; import type { ReactElement } from "react"; import type { NodeConfig, SamplerConfig } from "../types"; -import { LlmDialog } from "./llm/llm-dialog"; -import { CategoryDialog } from "./samplers/category-dialog"; -import { DatetimeDialog } from "./samplers/datetime-dialog"; -import { GaussianDialog } from "./samplers/gaussian-dialog"; -import { PersonDialog } from "./samplers/person-dialog"; -import { SubcategoryDialog } from "./samplers/subcategory-dialog"; -import { UniformDialog } from "./samplers/uniform-dialog"; -import { UuidDialog } from "./samplers/uuid-dialog"; +import { renderBlockDialog } from "../blocks/registry"; import { DialogShell } from "./shared/dialog-shell"; import { ValidationBanner } from "./shared/validation-banner"; -import { ExpressionDialog } from "./expression/expression-dialog"; type ConfigDialogProps = { open: boolean; @@ -41,65 +33,7 @@ export function ConfigDialog({ {config && (
- {config.kind === "sampler" && - config.sampler_type === "category" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "sampler" && - config.sampler_type === "subcategory" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "sampler" && config.sampler_type === "uniform" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "sampler" && - config.sampler_type === "gaussian" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "sampler" && - config.sampler_type === "datetime" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "sampler" && config.sampler_type === "uuid" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "sampler" && config.sampler_type === "person" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "llm" && ( - onUpdate(config.id, patch)} - /> - )} - {config.kind === "expression" && ( - onUpdate(config.id, patch)} - /> - )} + {renderBlockDialog(config, categoryOptions, onUpdate)}
)} 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 new file mode 100644 index 0000000000..8d3dabbcc1 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts @@ -0,0 +1,196 @@ +import type { CanvasNode, NodeConfig } from "../types"; +import { nodeDataFromConfig } from "../utils"; +import { removeRef, replaceRef } from "../utils/refs"; + +type NodeUpdateState = { + configs: Record; + nodes: CanvasNode[]; + nextId: number; + nextY: number; +}; + +type NodeUpdateResult = { + configs: Record; + nodes: CanvasNode[]; + nextId: number; + nextY: number; + activeConfigId: string; + dialogOpen: boolean; +}; + +export function updateNodeData( + nodes: CanvasNode[], + id: string, + config: NodeConfig, +): CanvasNode[] { + return nodes.map((node) => + node.id === id ? { ...node, data: nodeDataFromConfig(config) } : node, + ); +} + +export function findNodeIdByName( + configs: Record, + name: string, +): string | null { + const entry = Object.entries(configs).find( + ([, config]) => config.name === name, + ); + return entry ? entry[0] : null; +} + +export function buildNodeUpdate( + state: NodeUpdateState, + config: NodeConfig, +): NodeUpdateResult { + const node: CanvasNode = { + id: config.id, + type: "builder", + position: { x: 0, y: state.nextY }, + data: nodeDataFromConfig(config), + }; + return { + configs: { ...state.configs, [config.id]: config }, + nodes: [...state.nodes, node], + nextId: state.nextId + 1, + nextY: state.nextY + 140, + activeConfigId: config.id, + dialogOpen: true, + }; +} + +function updateTemplateFields( + config: NodeConfig, + updater: (value: string) => string, +): NodeConfig { + if (config.kind === "llm") { + const nextPrompt = updater(config.prompt); + const nextSystem = updater(config.system_prompt); + const nextOutput = + typeof config.output_format === "string" + ? updater(config.output_format) + : config.output_format; + if ( + nextPrompt === config.prompt && + nextSystem === config.system_prompt && + nextOutput === config.output_format + ) { + return config; + } + return { + ...config, + prompt: nextPrompt, + // biome-ignore lint/style/useNamingConvention: api schema + system_prompt: nextSystem, + // biome-ignore lint/style/useNamingConvention: api schema + output_format: nextOutput, + }; + } + if (config.kind === "expression") { + const nextExpr = updater(config.expr); + if (nextExpr === config.expr) { + return config; + } + return { ...config, expr: nextExpr }; + } + return config; +} + +export function applyRenameToConfig( + config: NodeConfig, + from: string, + to: string, +): NodeConfig { + let next = updateTemplateFields(config, (value) => + replaceRef(value, from, to), + ); + if ( + config.kind === "sampler" && + config.sampler_type === "subcategory" && + config.subcategory_parent === from + ) { + next = + next === config + ? { + ...config, + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_parent: to, + } + : { + ...next, + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_parent: to, + }; + } + return next; +} + +export function applyRemovalToConfig( + config: NodeConfig, + ref: string, +): NodeConfig { + let next = updateTemplateFields(config, (value) => removeRef(value, ref)); + if ( + config.kind === "sampler" && + config.sampler_type === "subcategory" && + config.subcategory_parent === ref + ) { + next = + next === config + ? { + ...config, + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_parent: "", + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_mapping: {}, + } + : { + ...next, + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_parent: "", + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_mapping: {}, + }; + } + return next; +} + +export function applyRenameToConfigs( + configs: Record, + from: string, + to: string, +): Record { + if (!from || from === to) { + return configs; + } + let next = configs; + for (const [id, config] of Object.entries(configs)) { + const updated = applyRenameToConfig(config, from, to); + if (updated !== config) { + if (next === configs) { + next = { ...configs }; + } + next[id] = updated; + } + } + return next; +} + +export function applyRemovalToConfigs( + configs: Record, + ref: string, +): Record { + if (!ref) { + return configs; + } + let next = configs; + for (const [id, config] of Object.entries(configs)) { + const updated = applyRemovalToConfig(config, ref); + if (updated !== config) { + if (next === configs) { + next = { ...configs }; + } + next[id] = updated; + } + } + return next; +} 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 9d0228b5d9..d3f51eb45d 100644 --- a/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts +++ b/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts @@ -16,16 +16,18 @@ import type { SamplerConfig, SamplerType, } from "../types"; -import { - isCategoryConfig, - isSubcategoryConfig, - makeExpressionConfig, - makeLlmConfig, - makeSamplerConfig, - nodeDataFromConfig, -} from "../utils"; +import { getBlockDefinition } from "../blocks/registry"; +import { isCategoryConfig, isSubcategoryConfig } from "../utils"; import { applyCanvasConnection, isValidCanvasConnection } from "../utils/graph"; import type { CanvasSnapshot } from "../utils/import"; +import { + applyRemovalToConfig, + applyRemovalToConfigs, + applyRenameToConfigs, + buildNodeUpdate, + findNodeIdByName, + updateNodeData, +} from "./canvas-lab-helpers"; type SheetView = "root" | "sampler" | "llm" | "expression"; @@ -52,26 +54,6 @@ type CanvasLabState = { isValidConnection: IsValidConnection; }; -function updateNodeData( - nodes: CanvasNode[], - id: string, - config: NodeConfig, -): CanvasNode[] { - return nodes.map((node) => - node.id === id ? { ...node, data: nodeDataFromConfig(config) } : node, - ); -} - -function findNodeIdByName( - configs: Record, - name: string, -): string | null { - const entry = Object.entries(configs).find( - ([, config]) => config.name === name, - ); - return entry ? entry[0] : null; -} - export const useCanvasLabStore = create((set, get) => ({ nodes: [], edges: [], @@ -88,63 +70,36 @@ export const useCanvasLabStore = create((set, get) => ({ set((state) => { const id = `n${state.nextId}`; const existing = Object.values(state.configs); - const config = makeSamplerConfig(id, type, existing); - const node: CanvasNode = { - id, - type: "builder", - position: { x: 0, y: state.nextY }, - data: nodeDataFromConfig(config), - }; - return { - configs: { ...state.configs, [id]: config }, - nodes: [...state.nodes, node], - nextId: state.nextId + 1, - nextY: state.nextY + 140, - activeConfigId: id, - dialogOpen: true, - }; + const definition = getBlockDefinition("sampler", type); + if (!definition) { + return state; + } + const config = definition.createConfig(id, existing); + return buildNodeUpdate(state, config); }); }, addLlmNode: (type) => { set((state) => { const id = `n${state.nextId}`; const existing = Object.values(state.configs); - const config = makeLlmConfig(id, type, existing); - const node: CanvasNode = { - id, - type: "builder", - position: { x: 0, y: state.nextY }, - data: nodeDataFromConfig(config), - }; - return { - configs: { ...state.configs, [id]: config }, - nodes: [...state.nodes, node], - nextId: state.nextId + 1, - nextY: state.nextY + 140, - activeConfigId: id, - dialogOpen: true, - }; + const definition = getBlockDefinition("llm", type); + if (!definition) { + return state; + } + const config = definition.createConfig(id, existing); + return buildNodeUpdate(state, config); }); }, addExpressionNode: () => { set((state) => { const id = `n${state.nextId}`; const existing = Object.values(state.configs); - const config = makeExpressionConfig(id, existing); - const node: CanvasNode = { - id, - type: "builder", - position: { x: 0, y: state.nextY }, - data: nodeDataFromConfig(config), - }; - return { - configs: { ...state.configs, [id]: config }, - nodes: [...state.nodes, node], - nextId: state.nextId + 1, - nextY: state.nextY + 140, - activeConfigId: id, - dialogOpen: true, - }; + const definition = getBlockDefinition("expression", "expression"); + if (!definition) { + return state; + } + const config = definition.createConfig(id, existing); + return buildNodeUpdate(state, config); }); }, loadCanvas: (snapshot) => @@ -166,6 +121,9 @@ export const useCanvasLabStore = create((set, get) => ({ return state; } const next = { ...current, ...patch } as NodeConfig; + const oldName = current.name; + const newName = next.name; + const nameChanged = oldName !== newName; let configs: Record = { ...state.configs, [id]: next, @@ -198,12 +156,9 @@ export const useCanvasLabStore = create((set, get) => ({ } if (isCategoryConfig(current)) { - const oldName = current.name; const nextCategory = isCategoryConfig(next) ? next : current; - const newName = nextCategory.name; const oldValues = current.values ?? []; const newValues = nextCategory.values ?? []; - const nameChanged = oldName !== newName; const valuesChanged = oldValues.length !== newValues.length || oldValues.some((value, index) => value !== newValues[index]); @@ -233,6 +188,10 @@ export const useCanvasLabStore = create((set, get) => ({ } } + if (nameChanged) { + configs = applyRenameToConfigs(configs, oldName, newName); + } + return { configs, nodes, edges }; }; set(applyUpdate); @@ -247,6 +206,7 @@ export const useCanvasLabStore = create((set, get) => ({ let edges = state.edges; let configs = state.configs; if (removedIds.length > 0) { + const removedNames: string[] = []; edges = edges.filter( (edge) => !( @@ -258,6 +218,9 @@ export const useCanvasLabStore = create((set, get) => ({ for (const id of removedIds) { const removed = configs[id]; delete configs[id]; + if (removed?.name) { + removedNames.push(removed.name); + } if (isCategoryConfig(removed)) { const removedName = removed.name; for (const config of Object.values(configs)) { @@ -277,6 +240,9 @@ export const useCanvasLabStore = create((set, get) => ({ } } } + for (const name of removedNames) { + configs = applyRemovalToConfigs(configs, name); + } } const nodes = applyNodeChanges(changes, state.nodes); @@ -285,7 +251,33 @@ export const useCanvasLabStore = create((set, get) => ({ set(applyNodesChange); }, onEdgesChange: (changes) => { - set((state) => ({ edges: applyEdgeChanges(changes, state.edges) })); + set((state) => { + const removedEdges = changes + .filter((change) => change.type === "remove") + .map((change) => state.edges.find((edge) => edge.id === change.id)) + .filter((edge): edge is Edge => Boolean(edge)); + + let configs = state.configs; + if (removedEdges.length > 0) { + for (const edge of removedEdges) { + const source = configs[edge.source]; + const target = configs[edge.target]; + if (!(source && target)) { + continue; + } + const updated = applyRemovalToConfig(target, source.name); + if (updated !== target) { + if (configs === state.configs) { + configs = { ...configs }; + } + configs[target.id] = updated; + } + } + } + + const edges = applyEdgeChanges(changes, state.edges); + return configs === state.configs ? { edges } : { edges, configs }; + }); }, onConnect: (connection) => { set((state) => { diff --git a/studio/frontend/src/features/canvas-lab/utils/import/helpers.ts b/studio/frontend/src/features/canvas-lab/utils/import/helpers.ts index de7bbdfb72..e8d543df97 100644 --- a/studio/frontend/src/features/canvas-lab/utils/import/helpers.ts +++ b/studio/frontend/src/features/canvas-lab/utils/import/helpers.ts @@ -1,3 +1,5 @@ +import { extractRefs as extractJinjaRefs } from "../refs"; + export function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -40,12 +42,5 @@ export function normalizeOutputFormat(value: unknown): string { } export function extractRefs(template: string): string[] { - const matches = template.matchAll(/{{\s*([a-zA-Z0-9_]+)\s*}}/g); - const refs = new Set(); - for (const match of matches) { - if (match[1]) { - refs.add(match[1]); - } - } - return Array.from(refs); + return extractJinjaRefs(template); } diff --git a/studio/frontend/src/features/canvas-lab/utils/index.ts b/studio/frontend/src/features/canvas-lab/utils/index.ts index 9e9f8d7f93..496ceef43a 100644 --- a/studio/frontend/src/features/canvas-lab/utils/index.ts +++ b/studio/frontend/src/features/canvas-lab/utils/index.ts @@ -333,26 +333,25 @@ export function getConfigErrors(config: NodeConfig | null): string[] { errors.push("Subcategory needs a parent category column."); } } - if (config.kind === "llm" && !config.prompt.trim()) { - errors.push("Prompt is required."); - } - if (config.kind === "llm" && config.llm_type === "code") { - if (!config.code_lang) { + if (config.kind === "llm") { + if (!config.model_alias.trim()) { + errors.push("Model alias is required."); + } + if (!config.prompt.trim()) { + errors.push("Prompt is required."); + } + if (config.llm_type === "code" && !config.code_lang) { errors.push("Code language is required."); } - } - if ( - config.kind === "llm" && - config.llm_type === "structured" && - typeof config.output_format === "string" - ) { - if (!config.output_format.trim()) { - errors.push("Output format is required."); - } else { - try { - JSON.parse(config.output_format); - } catch { - errors.push("Output format must be valid JSON."); + if (config.llm_type === "structured") { + if (!config.output_format?.trim()) { + errors.push("Output format is required."); + } else { + try { + JSON.parse(config.output_format); + } catch { + errors.push("Output format must be valid JSON."); + } } } } diff --git a/studio/frontend/src/features/canvas-lab/utils/refs.ts b/studio/frontend/src/features/canvas-lab/utils/refs.ts new file mode 100644 index 0000000000..91f0e8e869 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/utils/refs.ts @@ -0,0 +1,49 @@ +const JINJA_REF_RE = /{{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}/g; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function extractRefs(template: string): string[] { + if (!template) { + return []; + } + const refs = new Set(); + for (const match of template.matchAll(JINJA_REF_RE)) { + if (match[1]) { + refs.add(match[1]); + } + } + return Array.from(refs); +} + +export function replaceRef( + template: string, + from: string, + to: string, +): string { + if (!template || from === to) { + return template; + } + const pattern = new RegExp(`{{\\s*${escapeRegExp(from)}\\s*}}`, "g"); + return template.replace(pattern, `{{ ${to} }}`); +} + +export function removeRef(template: string, ref: string): string { + if (!template) { + return template; + } + const escaped = escapeRegExp(ref); + const fullLine = new RegExp(`^\\s*{{\\s*${escaped}\\s*}}\\s*$`); + const inline = new RegExp(`{{\\s*${escaped}\\s*}}`, "g"); + const next = template + .split("\n") + .flatMap((line) => { + if (fullLine.test(line)) { + return []; + } + return [line.replace(inline, "").replace(/\s+$/g, "")]; + }) + .join("\n"); + return next; +}