refactor: centralize block definitions and dialogs into registry, streamline node updates using helper utilities
This commit is contained in:
parent
72be93e9b5
commit
d29643dbb6
8 changed files with 640 additions and 310 deletions
283
studio/frontend/src/features/canvas-lab/blocks/registry.tsx
Normal file
283
studio/frontend/src/features/canvas-lab/blocks/registry.tsx
Normal 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 });
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
studio/frontend/src/features/canvas-lab/utils/refs.ts
Normal file
49
studio/frontend/src/features/canvas-lab/utils/refs.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue