refactor: centralize block definitions and dialogs into registry, streamline node updates using helper utilities

This commit is contained in:
shine1i 2026-02-04 15:42:46 +01:00
commit d29643dbb6
8 changed files with 640 additions and 310 deletions

View file

@ -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<NodeConfig>) => 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" ? (
<CategoryDialog
config={config}
onUpdate={(patch) => 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" ? (
<SubcategoryDialog
config={config}
categoryOptions={categoryOptions}
onUpdate={(patch) => 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" ? (
<UniformDialog
config={config}
onUpdate={(patch) => 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" ? (
<GaussianDialog
config={config}
onUpdate={(patch) => 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" ? (
<DatetimeDialog
config={config}
onUpdate={(patch) => 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" ? (
<UuidDialog
config={config}
onUpdate={(patch) => 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" ? (
<PersonDialog
config={config}
onUpdate={(patch) => 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" ? (
<LlmDialog
config={config}
onUpdate={(patch) => 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" ? (
<LlmDialog
config={config}
onUpdate={(patch) => 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" ? (
<LlmDialog
config={config}
onUpdate={(patch) => 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" ? (
<ExpressionDialog
config={config}
onUpdate={(patch) => 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<NodeConfig>) => void,
): ReactElement | null {
const definition = getBlockDefinitionForConfig(config);
if (!definition || !config) {
return null;
}
return definition.renderDialog({ config, categoryOptions, onUpdate });
}

View file

@ -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<SheetView, SheetKind | null> = {
root: null,
sampler: "sampler",
llm: "llm",
expression: "expression",
};
function BlockSheetButton({
icon,
@ -228,43 +122,31 @@ export function BlockSheet({
<div className="px-6 py-4">
<div className="mt-4 flex flex-col gap-2">
{view === "root" &&
MAIN_SHEET_ITEMS.map((item) => (
BLOCK_GROUPS.map((item) => (
<BlockSheetButton
key={item.kind}
icon={item.icon}
title={item.title}
description={item.description}
onClick={() => onViewChange(nextViewForKind(item.kind))}
onClick={() => onViewChange(item.kind)}
/>
))}
{view === "sampler" &&
SAMPLER_ITEMS.map((item) => (
{view !== "root" &&
getBlocksForKind(VIEW_KIND[view] ?? "sampler").map((item) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
onClick={() => onAddSampler(item.type)}
/>
))}
{view === "llm" &&
LLM_ITEMS.map((item) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
onClick={() => onAddLlm(item.type)}
/>
))}
{view === "expression" &&
EXPRESSION_ITEMS.map((item) => (
<BlockSheetButton
key={item.title}
icon={item.icon}
title={item.title}
description={item.description}
onClick={onAddExpression}
onClick={() => {
if (item.kind === "sampler") {
onAddSampler(item.type as SamplerType);
} else if (item.kind === "llm") {
onAddLlm(item.type as LlmType);
} else {
onAddExpression();
}
}}
/>
))}
</div>

View file

@ -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 && (
<div className="space-y-4">
<ValidationBanner config={config} />
{config.kind === "sampler" &&
config.sampler_type === "category" && (
<CategoryDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" &&
config.sampler_type === "subcategory" && (
<SubcategoryDialog
config={config}
categoryOptions={categoryOptions}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" && config.sampler_type === "uniform" && (
<UniformDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" &&
config.sampler_type === "gaussian" && (
<GaussianDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" &&
config.sampler_type === "datetime" && (
<DatetimeDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" && config.sampler_type === "uuid" && (
<UuidDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" && config.sampler_type === "person" && (
<PersonDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "llm" && (
<LlmDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "expression" && (
<ExpressionDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{renderBlockDialog(config, categoryOptions, onUpdate)}
</div>
)}
<DialogFooter>

View file

@ -0,0 +1,196 @@
import type { CanvasNode, NodeConfig } from "../types";
import { nodeDataFromConfig } from "../utils";
import { removeRef, replaceRef } from "../utils/refs";
type NodeUpdateState = {
configs: Record<string, NodeConfig>;
nodes: CanvasNode[];
nextId: number;
nextY: number;
};
type NodeUpdateResult = {
configs: Record<string, NodeConfig>;
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<string, NodeConfig>,
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<string, NodeConfig>,
from: string,
to: string,
): Record<string, NodeConfig> {
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<string, NodeConfig>,
ref: string,
): Record<string, NodeConfig> {
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;
}

View file

@ -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<string, NodeConfig>,
name: string,
): string | null {
const entry = Object.entries(configs).find(
([, config]) => config.name === name,
);
return entry ? entry[0] : null;
}
export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
nodes: [],
edges: [],
@ -88,63 +70,36 @@ export const useCanvasLabStore = create<CanvasLabState>((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<CanvasLabState>((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<string, NodeConfig> = {
...state.configs,
[id]: next,
@ -198,12 +156,9 @@ export const useCanvasLabStore = create<CanvasLabState>((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<CanvasLabState>((set, get) => ({
}
}
if (nameChanged) {
configs = applyRenameToConfigs(configs, oldName, newName);
}
return { configs, nodes, edges };
};
set(applyUpdate);
@ -247,6 +206,7 @@ export const useCanvasLabStore = create<CanvasLabState>((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<CanvasLabState>((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<CanvasLabState>((set, get) => ({
}
}
}
for (const name of removedNames) {
configs = applyRemovalToConfigs(configs, name);
}
}
const nodes = applyNodeChanges<CanvasNode>(changes, state.nodes);
@ -285,7 +251,33 @@ export const useCanvasLabStore = create<CanvasLabState>((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) => {

View file

@ -1,3 +1,5 @@
import { extractRefs as extractJinjaRefs } from "../refs";
export function isRecord(value: unknown): value is Record<string, unknown> {
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<string>();
for (const match of matches) {
if (match[1]) {
refs.add(match[1]);
}
}
return Array.from(refs);
return extractJinjaRefs(template);
}

View file

@ -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.");
}
}
}
}

View file

@ -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<string>();
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;
}