canvaslab v1

This commit is contained in:
shine1i 2026-02-04 14:06:38 +01:00
commit 1d5d1b625b
13 changed files with 429 additions and 118 deletions

View file

@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
test/
*.local
.env
.env.*

View file

@ -30,13 +30,17 @@ function SheetPortal({
function SheetOverlay({
className,
position = "fixed",
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
}: React.ComponentProps<typeof SheetPrimitive.Overlay> & {
position?: "fixed" | "absolute";
}) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs inset-0 z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}
@ -49,19 +53,31 @@ function SheetContent({
children,
side = "right",
showCloseButton = true,
container,
position = "fixed",
overlayClassName,
overlayPosition,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
container?: HTMLElement | null;
position?: "fixed" | "absolute";
overlayClassName?: string;
overlayPosition?: "fixed" | "absolute";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPortal container={container ?? undefined}>
<SheetOverlay
className={overlayClassName}
position={overlayPosition ?? position}
/>
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}

View file

@ -37,6 +37,7 @@ export function CanvasLabPage(): ReactElement {
onConnect,
addSamplerNode,
addLlmNode,
addExpressionNode,
openConfig,
updateConfig,
isValidConnection,
@ -55,6 +56,7 @@ export function CanvasLabPage(): ReactElement {
onConnect: state.onConnect,
addSamplerNode: state.addSamplerNode,
addLlmNode: state.addLlmNode,
addExpressionNode: state.addExpressionNode,
openConfig: state.openConfig,
updateConfig: state.updateConfig,
isValidConnection: state.isValidConnection,
@ -183,6 +185,7 @@ export function CanvasLabPage(): ReactElement {
onViewChange={setSheetView}
onAddSampler={addSamplerNode}
onAddLlm={addLlmNode}
onAddExpression={addExpressionNode}
/>
</Panel>
</ReactFlow>

View file

@ -19,7 +19,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import type { LlmType, SamplerType } from "../types";
type SheetView = "root" | "sampler" | "llm";
type SheetView = "root" | "sampler" | "llm" | "expression";
type BlockSheetProps = {
container: HTMLDivElement | null;
@ -27,6 +27,7 @@ type BlockSheetProps = {
onViewChange: (view: SheetView) => void;
onAddSampler: (type: SamplerType) => void;
onAddLlm: (type: LlmType) => void;
onAddExpression: () => void;
};
function getSheetTitle(view: SheetView): string {
@ -36,6 +37,9 @@ function getSheetTitle(view: SheetView): string {
if (view === "sampler") {
return "Sampler blocks";
}
if (view === "expression") {
return "Expression blocks";
}
return "LLM blocks";
}
@ -52,6 +56,12 @@ const MAIN_SHEET_ITEMS = [
description: "Text + structured blocks.",
icon: SparklesIcon,
},
{
kind: "expression" as const,
title: "Expression",
description: "Derived columns with Jinja.",
icon: CodeIcon,
},
];
const SAMPLER_ITEMS = [
@ -120,12 +130,31 @@ const LLM_ITEMS = [
},
];
const EXPRESSION_ITEMS = [
{
title: "Expression",
description: "Transform columns with Jinja.",
icon: CodeIcon,
},
];
function nextViewForKind(kind: "sampler" | "llm" | "expression"): SheetView {
if (kind === "sampler") {
return "sampler";
}
if (kind === "expression") {
return "expression";
}
return "llm";
}
export function BlockSheet({
container,
view,
onViewChange,
onAddSampler,
onAddLlm,
onAddExpression,
}: BlockSheetProps): ReactElement {
const title = getSheetTitle(view);
return (
@ -138,8 +167,10 @@ export function BlockSheet({
<SheetContent
side="right"
container={container}
position="absolute"
overlayPosition="absolute"
className="absolute gap-0 p-0 shadow-none"
overlayClassName="absolute inset-0 bg-transparent backdrop-blur-0 supports-backdrop-filter:backdrop-blur-0 data-open:fade-in-0 data-closed:fade-out-0"
overlayClassName="bg-transparent pointer-events-none"
>
<SheetHeader className="border-b border-border/60 px-6 py-5">
<div className="flex items-center gap-2">
@ -163,9 +194,7 @@ export function BlockSheet({
<button
key={item.kind}
type="button"
onClick={() =>
onViewChange(item.kind === "sampler" ? "sampler" : "llm")
}
onClick={() => onViewChange(nextViewForKind(item.kind))}
className="flex w-full items-center gap-3 rounded-2xl border border-border/60 bg-white px-3 py-3 text-left transition hover:border-border hover:bg-muted/40"
>
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground">
@ -235,6 +264,31 @@ export function BlockSheet({
/>
</button>
))}
{view === "expression" &&
EXPRESSION_ITEMS.map((item) => (
<button
key={item.title}
type="button"
onClick={onAddExpression}
className="flex w-full items-center gap-3 rounded-2xl border border-border/60 bg-white px-3 py-3 text-left transition hover:border-border hover:bg-muted/40"
>
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground">
<HugeiconsIcon icon={item.icon} className="size-4" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-foreground">
{item.title}
</p>
<p className="text-[11px] text-muted-foreground">
{item.description}
</p>
</div>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
</button>
))}
</div>
</div>
</SheetContent>

View file

@ -1,5 +1,9 @@
import { cn } from "@/lib/utils";
import { Database02Icon, SparklesIcon } from "@hugeicons/core-free-icons";
import {
CodeIcon,
Database02Icon,
SparklesIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { NodeProps } from "@xyflow/react";
import { Handle, Position } from "@xyflow/react";
@ -15,6 +19,10 @@ const NODE_META = {
icon: SparklesIcon,
tone: "bg-purple-50 text-purple-600 border-purple-100",
},
expression: {
icon: CodeIcon,
tone: "bg-sky-50 text-sky-600 border-sky-100",
},
} as const;
function CanvasNodeBase({

View file

@ -12,6 +12,7 @@ import { UniformDialog } from "./samplers/uniform-dialog";
import { UuidDialog } from "./samplers/uuid-dialog";
import { DialogShell } from "./shared/dialog-shell";
import { ValidationBanner } from "./shared/validation-banner";
import { ExpressionDialog } from "./expression/expression-dialog";
type ConfigDialogProps = {
open: boolean;
@ -93,6 +94,12 @@ export function ConfigDialog({
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "expression" && (
<ExpressionDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
</div>
)}
<DialogFooter>

View file

@ -0,0 +1,83 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
import { NameField } from "../shared/name-field";
const DTYPE_OPTIONS: ExpressionDtype[] = ["str", "int", "float", "bool"];
type ExpressionDialogProps = {
config: ExpressionConfig;
onUpdate: (patch: Partial<ExpressionConfig>) => void;
};
export function ExpressionDialog({
config,
onUpdate,
}: ExpressionDialogProps): ReactElement {
const dtypeId = `${config.id}-dtype`;
const exprId = `${config.id}-expr`;
const updateField = <K extends keyof ExpressionConfig>(
key: K,
value: ExpressionConfig[K],
) => {
onUpdate({ [key]: value } as Partial<ExpressionConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={dtypeId}
>
Output type
</label>
<Select
value={config.dtype}
onValueChange={(value) =>
updateField("dtype", value as ExpressionDtype)
}
>
<SelectTrigger className="nodrag w-full" id={dtypeId}>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{DTYPE_OPTIONS.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={exprId}
>
Expression (Jinja2)
</label>
<Textarea
id={exprId}
className="nodrag"
placeholder="{{ category_1 }} - {{ subcategory_1 }}"
value={config.expr}
onChange={(event) => updateField("expr", event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Use Jinja2. Reference columns like {"{{ column_name }}"}.
</p>
</div>
</div>
);
}

View file

@ -122,6 +122,9 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
updateField("output_format", event.target.value)
}
/>
<p className="text-xs text-muted-foreground">
Paste a JSON schema object or minimal shape.
</p>
</div>
)}
<div className="grid gap-2">

View file

@ -4,7 +4,6 @@ import {
type EdgeChange,
type IsValidConnection,
type NodeChange,
addEdge,
applyEdgeChanges,
applyNodeChanges,
} from "@xyflow/react";
@ -19,12 +18,14 @@ import type {
import {
isCategoryConfig,
isSubcategoryConfig,
makeExpressionConfig,
makeLlmConfig,
makeSamplerConfig,
nodeDataFromConfig,
} from "../utils";
import { applyCanvasConnection, isValidCanvasConnection } from "../utils/graph";
type SheetView = "root" | "sampler" | "llm";
type SheetView = "root" | "sampler" | "llm" | "expression";
type CanvasLabState = {
nodes: CanvasNode[];
@ -40,6 +41,7 @@ type CanvasLabState = {
openConfig: (id: string) => void;
addSamplerNode: (type: SamplerType) => void;
addLlmNode: (type: LlmType) => void;
addExpressionNode: () => void;
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
@ -57,16 +59,6 @@ function updateNodeData(
);
}
function buildPromptWithRef(prompt: string, ref: string): string {
if (prompt.includes(ref)) {
return prompt;
}
if (prompt.trim()) {
return `${prompt}\n${ref}`;
}
return ref;
}
function findNodeIdByName(
configs: Record<string, NodeConfig>,
name: string,
@ -77,34 +69,6 @@ function findNodeIdByName(
return entry ? entry[0] : null;
}
function syncSubcategoryMapping(
subcategory: SamplerConfig,
parent: NodeConfig,
): SamplerConfig {
if (!isCategoryConfig(parent)) {
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
};
}
const nextMapping: Record<string, string[]> = {
...(subcategory.subcategory_mapping ?? {}),
};
for (const value of parent.values ?? []) {
if (!nextMapping[value]) {
nextMapping[value] = [];
}
}
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: nextMapping,
};
}
export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
nodes: [],
edges: [],
@ -159,6 +123,27 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
};
});
},
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,
};
});
},
updateConfig: (id, patch) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyUpdate = (state: CanvasLabState) => {
@ -289,55 +274,17 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
set((state) => ({ edges: applyEdgeChanges(changes, state.edges) }));
},
onConnect: (connection) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyConnect = (state: CanvasLabState) => {
const source = connection.source
? state.configs[connection.source]
: undefined;
const target = connection.target
? state.configs[connection.target]
: undefined;
if (isSubcategoryConfig(target) && !isCategoryConfig(source)) {
return state;
}
const edges = addEdge(connection, state.edges);
if (!(connection.source && connection.target)) {
return { edges };
}
if (!(source && target)) {
return { edges };
}
const sourceName = source.name;
let configs = state.configs;
if (target.kind === "llm") {
const ref = `{{ ${sourceName} }}`;
const nextPrompt = buildPromptWithRef(target.prompt ?? "", ref);
const next = { ...target, prompt: nextPrompt };
configs = { ...configs, [target.id]: next };
return { edges, configs };
}
if (isSubcategoryConfig(target)) {
const next = syncSubcategoryMapping(target, source);
configs = { ...configs, [target.id]: next };
return { edges, configs };
}
return { edges };
};
set(applyConnect);
},
isValidConnection: (connection) => {
if (!(connection.source && connection.target)) {
return false;
}
const configs = get().configs;
const source = configs[connection.source];
const target = configs[connection.target];
if (isSubcategoryConfig(target)) {
return isCategoryConfig(source);
}
return connection.source !== connection.target;
set((state) => {
const result = applyCanvasConnection(
connection,
state.configs,
state.edges,
);
return result.configs
? { edges: result.edges, configs: result.configs }
: { edges: result.edges };
});
},
isValidConnection: (connection) =>
isValidCanvasConnection(connection, get().configs),
}));

View file

@ -11,10 +11,12 @@ export type SamplerType =
export type LlmType = "text" | "structured" | "code";
export type ExpressionDtype = "str" | "int" | "float" | "bool";
export type CanvasNodeData = {
title: string;
name: string;
kind: "sampler" | "llm";
kind: "sampler" | "llm" | "expression";
subtype: string;
};
@ -75,4 +77,12 @@ export type LlmConfig = {
output_format?: string;
};
export type NodeConfig = SamplerConfig | LlmConfig;
export type ExpressionConfig = {
id: string;
kind: "expression";
name: string;
expr: string;
dtype: ExpressionDtype;
};
export type NodeConfig = SamplerConfig | LlmConfig | ExpressionConfig;

View file

@ -0,0 +1,107 @@
import { type Connection, type Edge, addEdge } from "@xyflow/react";
import type { NodeConfig, SamplerConfig } from "../types";
import {
isCategoryConfig,
isExpressionConfig,
isLlmConfig,
isSubcategoryConfig,
} from "./index";
function buildTemplateWithRef(template: string, ref: string): string {
if (template.includes(ref)) {
return template;
}
if (template.trim()) {
return `${template}\n${ref}`;
}
return ref;
}
function syncSubcategoryMapping(
subcategory: SamplerConfig,
parent: NodeConfig,
): SamplerConfig {
if (!isCategoryConfig(parent)) {
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
};
}
const nextMapping: Record<string, string[]> = {
...(subcategory.subcategory_mapping ?? {}),
};
for (const value of parent.values ?? []) {
if (!nextMapping[value]) {
nextMapping[value] = [];
}
}
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: nextMapping,
};
}
export function isValidCanvasConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
): boolean {
if (!(connection.source && connection.target)) {
return false;
}
if (connection.source === connection.target) {
return false;
}
const source = configs[connection.source];
const target = configs[connection.target];
if (!(source && target)) {
return false;
}
if (isSubcategoryConfig(target)) {
return isCategoryConfig(source);
}
if (isLlmConfig(target) || isExpressionConfig(target)) {
return true;
}
return false;
}
export function applyCanvasConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
edges: Edge[],
): { edges: Edge[]; configs?: Record<string, NodeConfig> } {
if (!isValidCanvasConnection(connection, configs)) {
return { edges };
}
const source = connection.source ? configs[connection.source] : null;
const target = connection.target ? configs[connection.target] : null;
if (!(source && target)) {
return { edges };
}
const nextEdges = addEdge(connection, edges);
if (isLlmConfig(target)) {
const ref = `{{ ${source.name} }}`;
const next = {
...target,
prompt: buildTemplateWithRef(target.prompt ?? "", ref),
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isExpressionConfig(target)) {
const ref = `{{ ${source.name} }}`;
const next = {
...target,
expr: buildTemplateWithRef(target.expr ?? "", ref),
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isSubcategoryConfig(target)) {
const next = syncSubcategoryMapping(target, source);
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
return { edges: nextEdges };
}

View file

@ -1,5 +1,7 @@
import type {
CanvasNodeData,
ExpressionConfig,
ExpressionDtype,
LlmConfig,
LlmType,
NodeConfig,
@ -23,6 +25,13 @@ const LLM_LABELS: Record<LlmType, string> = {
code: "LLM Code",
};
const EXPRESSION_LABELS: Record<ExpressionDtype, string> = {
str: "Text",
int: "Int",
float: "Float",
bool: "Bool",
};
export function nextName(existing: NodeConfig[], prefix: string): string {
const counts = existing
.map((item) => item.name)
@ -161,8 +170,8 @@ export function makeLlmConfig(
llm_type: llmType,
name,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: "local-text",
prompt: "Write a response about {{ sampler_1 }}.",
model_alias: "stepfun/step-3.5-flash:free",
prompt: "Write a response.",
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: "",
// biome-ignore lint/style/useNamingConvention: api schema
@ -173,6 +182,19 @@ export function makeLlmConfig(
};
}
export function makeExpressionConfig(
id: string,
existing: NodeConfig[],
): ExpressionConfig {
return {
id,
kind: "expression",
name: nextName(existing, "expr"),
expr: "",
dtype: "str",
};
}
export function labelForSampler(type: SamplerType): string {
return SAMPLER_LABELS[type] ?? "Sampler";
}
@ -181,6 +203,10 @@ export function labelForLlm(type: LlmType): string {
return LLM_LABELS[type] ?? "LLM";
}
export function labelForExpression(type: ExpressionDtype): string {
return EXPRESSION_LABELS[type] ?? "Expression";
}
export function nodeDataFromConfig(config: NodeConfig): CanvasNodeData {
if (config.kind === "sampler") {
return {
@ -190,6 +216,14 @@ export function nodeDataFromConfig(config: NodeConfig): CanvasNodeData {
name: config.name,
};
}
if (config.kind === "expression") {
return {
title: "Expression",
kind: "expression",
subtype: labelForExpression(config.dtype),
name: config.name,
};
}
return {
title: "LLM",
kind: "llm",
@ -228,6 +262,12 @@ export function isLlmConfig(
return Boolean(config && config.kind === "llm");
}
export function isExpressionConfig(
config: NodeConfig | null | undefined,
): config is ExpressionConfig {
return Boolean(config && config.kind === "expression");
}
function parseNumber(value?: string): number | null {
if (!value) {
return null;
@ -296,16 +336,29 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
if (config.kind === "llm" && !config.prompt.trim()) {
errors.push("Prompt is required.");
}
if (config.kind === "llm" && config.llm_type === "code") {
if (!config.code_lang) {
errors.push("Code language is required.");
}
}
if (
config.kind === "llm" &&
config.llm_type === "structured" &&
typeof config.output_format === "string" &&
config.output_format.trim()
typeof config.output_format === "string"
) {
try {
JSON.parse(config.output_format);
} catch {
errors.push("Output format must be valid JSON.");
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.kind === "expression") {
if (!config.expr.trim()) {
errors.push("Expression is required.");
}
}
return errors;

View file

@ -1,6 +1,7 @@
import type { Edge } from "@xyflow/react";
import type {
CanvasNode,
ExpressionConfig,
LlmConfig,
NodeConfig,
SamplerConfig,
@ -182,6 +183,22 @@ function buildLlmColumn(
};
}
function buildExpressionColumn(
config: ExpressionConfig,
errors: string[],
): Record<string, unknown> {
if (!config.expr.trim()) {
errors.push(`Expression ${config.name}: expr required.`);
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "expression",
name: config.name,
expr: config.expr,
dtype: config.dtype,
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build
export function buildCanvasPayload(
configs: Record<string, NodeConfig>,
@ -217,14 +234,16 @@ export function buildCanvasPayload(
sampler_type: config.sampler_type,
params: buildSamplerParams(config, errors),
});
continue;
} else if (config.kind === "llm") {
columns.push(buildLlmColumn(config, errors));
if (config.model_alias) {
modelAliases.add(config.model_alias);
}
nameToConfig.set(config.name, config);
} else {
columns.push(buildExpressionColumn(config, errors));
nameToConfig.set(config.name, config);
}
columns.push(buildLlmColumn(config, errors));
if (config.model_alias) {
modelAliases.add(config.model_alias);
}
nameToConfig.set(config.name, config);
}
for (const config of Object.values(configs)) {