import export

This commit is contained in:
shine1i 2026-02-04 14:22:03 +01:00
commit af2a47dbd9
4 changed files with 634 additions and 0 deletions

View file

@ -17,9 +17,11 @@ import { previewCanvas } from "./api";
import { BlockSheet } from "./components/block-sheet";
import { CanvasNode } from "./components/canvas-node";
import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { useCanvasLabStore } from "./stores/canvas-lab";
import type { CanvasNodeData, SamplerConfig } from "./types";
import { isCategoryConfig } from "./utils";
import { importCanvasPayload } from "./utils/import";
import { buildCanvasPayload } from "./utils/payload";
const NODE_TYPES: NodeTypes = { builder: CanvasNode };
@ -43,6 +45,7 @@ export function CanvasLabPage(): ReactElement {
isValidConnection,
setSheetView,
setDialogOpen,
loadCanvas,
} = useCanvasLabStore(
useShallow((state) => ({
nodes: state.nodes,
@ -62,12 +65,14 @@ export function CanvasLabPage(): ReactElement {
isValidConnection: state.isValidConnection,
setSheetView: state.setSheetView,
setDialogOpen: state.setDialogOpen,
loadCanvas: state.loadCanvas,
})),
);
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
null,
);
const [previewLoading, setPreviewLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [statusMessage, setStatusMessage] = useState<{
tone: "success" | "error";
text: string;
@ -114,6 +119,47 @@ export function CanvasLabPage(): ReactElement {
}
};
const handleCopyRecipe = async (): Promise<void> => {
setStatusMessage(null);
const { payload, errors } = buildCanvasPayload(configs, nodes, edges);
if (errors.length > 0) {
setStatusMessage({
tone: "error",
text: errors[0] ?? "Fix config errors before copy.",
});
return;
}
if (!navigator.clipboard) {
setStatusMessage({
tone: "error",
text: "Clipboard not available.",
});
return;
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
setStatusMessage({
tone: "success",
text: "Recipe copied to clipboard.",
});
} catch (error) {
setStatusMessage({
tone: "error",
text: error instanceof Error ? error.message : "Copy failed.",
});
}
};
const handleImport = (value: string): string | null => {
const result = importCanvasPayload(value);
if (result.errors.length > 0 || !result.snapshot) {
return result.errors[0] ?? "Invalid payload.";
}
loadCanvas(result.snapshot);
setStatusMessage({ tone: "success", text: "Recipe imported." });
return null;
};
return (
<div className="min-h-screen bg-background">
<main className="w-full px-6 py-8">
@ -139,6 +185,24 @@ export function CanvasLabPage(): ReactElement {
)}
</div>
<div className="flex items-center justify-start gap-2 lg:justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCopyRecipe}
className="text-xs"
>
Copy recipe
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setImportOpen(true)}
className="text-xs"
>
Import
</Button>
<Button
type="button"
size="sm"
@ -198,6 +262,11 @@ export function CanvasLabPage(): ReactElement {
categoryOptions={categoryOptions}
onUpdate={updateConfig}
/>
<ImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImport={handleImport}
/>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, useEffect, useState } from "react";
type ImportDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onImport: (value: string) => string | null;
};
export function ImportDialog({
open,
onOpenChange,
onImport,
}: ImportDialogProps): ReactElement {
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
const payloadId = "canvas-import-payload";
useEffect(() => {
if (!open) {
setValue("");
setError(null);
}
}, [open]);
const handleImport = () => {
const message = onImport(value);
if (message) {
setError(message);
return;
}
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Import recipe</DialogTitle>
</DialogHeader>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={payloadId}
>
JSON payload
</label>
<Textarea
id={payloadId}
className="nodrag min-h-[220px]"
placeholder='{"recipe": { "columns": [] }}'
value={value}
onChange={(event) => setValue(event.target.value)}
/>
{error && (
<p className="text-xs text-rose-600" role="alert">
{error}
</p>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleImport}>
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -24,6 +24,7 @@ import {
nodeDataFromConfig,
} from "../utils";
import { applyCanvasConnection, isValidCanvasConnection } from "../utils/graph";
import type { CanvasSnapshot } from "../utils/import";
type SheetView = "root" | "sampler" | "llm" | "expression";
@ -43,6 +44,7 @@ type CanvasLabState = {
addLlmNode: (type: LlmType) => void;
addExpressionNode: () => void;
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
loadCanvas: (snapshot: CanvasSnapshot) => void;
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
onConnect: (connection: Connection) => void;
@ -144,6 +146,17 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
};
});
},
loadCanvas: (snapshot) =>
set({
configs: snapshot.configs,
nodes: snapshot.nodes,
edges: snapshot.edges,
nextId: snapshot.nextId,
nextY: snapshot.nextY,
activeConfigId: null,
dialogOpen: false,
sheetView: "root",
}),
updateConfig: (id, patch) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyUpdate = (state: CanvasLabState) => {

View file

@ -0,0 +1,475 @@
import type { Edge } from "@xyflow/react";
import type {
CanvasNode,
ExpressionConfig,
ExpressionDtype,
LlmConfig,
NodeConfig,
SamplerConfig,
SamplerType,
} from "../types";
import { nodeDataFromConfig } from "./index";
export type CanvasSnapshot = {
configs: Record<string, NodeConfig>;
nodes: CanvasNode[];
edges: Edge[];
nextId: number;
nextY: number;
};
export type ImportResult = {
errors: string[];
snapshot: CanvasSnapshot | null;
};
type RecipeInput = {
columns?: unknown;
model_configs?: unknown;
model_providers?: unknown;
processors?: unknown;
};
type UiInput = {
nodes?: unknown;
edges?: unknown;
};
const SAMPLER_TYPES: SamplerType[] = [
"category",
"subcategory",
"uniform",
"gaussian",
"datetime",
"uuid",
"person",
];
const EXPRESSION_DTYPES: ExpressionDtype[] = ["str", "int", "float", "bool"];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readNumberString(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
if (typeof value === "string") {
return value;
}
return "";
}
function parseJson(
input: string,
): { data: unknown | null; error?: string } {
try {
return { data: JSON.parse(input) };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : "Invalid JSON.",
};
}
}
function normalizeOutputFormat(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (isRecord(value)) {
return JSON.stringify(value, null, 2);
}
return "";
}
function parseSampler(
column: Record<string, unknown>,
name: string,
id: string,
errors: string[],
): SamplerConfig | null {
const samplerType = readString(column.sampler_type);
if (!samplerType || !SAMPLER_TYPES.includes(samplerType as SamplerType)) {
errors.push(`Sampler ${name}: unsupported sampler_type.`);
return null;
}
const params = isRecord(column.params) ? column.params : {};
if (samplerType === "category") {
const values = Array.isArray(params.values)
? params.values.filter((item) => typeof item === "string")
: [];
const weights = Array.isArray(params.weights)
? params.weights.map((item) => (typeof item === "number" ? item : null))
: [];
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
name,
values,
weights,
};
}
if (samplerType === "subcategory") {
const mapping: Record<string, string[]> = {};
if (isRecord(params.values)) {
for (const [key, value] of Object.entries(params.values)) {
if (Array.isArray(value)) {
mapping[key] = value.filter((item) => typeof item === "string");
}
}
}
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "subcategory",
name,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: readString(params.category) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: mapping,
};
}
if (samplerType === "uniform") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uniform",
name,
low: readNumberString(params.low),
high: readNumberString(params.high),
};
}
if (samplerType === "gaussian") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "gaussian",
name,
mean: readNumberString(params.mean),
std: readNumberString(params.std),
};
}
if (samplerType === "datetime") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "datetime",
name,
// biome-ignore lint/style/useNamingConvention: api schema
datetime_start: readString(params.start) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_end: readString(params.end) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_unit: readString(params.unit) ?? "",
};
}
if (samplerType === "uuid") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uuid",
name,
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: readString(params.format) ?? "",
};
}
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "person",
name,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: readString(params.locale) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: readString(params.sex) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: readString(params.age_range) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_city: readString(params.city) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas:
typeof params.with_synthetic_personas === "boolean"
? params.with_synthetic_personas
: false,
// biome-ignore lint/style/useNamingConvention: api schema
person_sample_dataset_when_available:
typeof params.sample_dataset_when_available === "boolean"
? params.sample_dataset_when_available
: false,
};
}
function parseLlm(
column: Record<string, unknown>,
name: string,
id: string,
): LlmConfig {
const columnType = readString(column.column_type) ?? "llm-text";
let llmType: LlmConfig["llm_type"] = "text";
if (columnType === "llm-structured") {
llmType = "structured";
} else if (columnType === "llm-code") {
llmType = "code";
}
return {
id,
kind: "llm",
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: llmType,
name,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: readString(column.model_alias) ?? "",
prompt: readString(column.prompt) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: readString(column.system_prompt) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: readString(column.code_lang) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
output_format: normalizeOutputFormat(column.output_format),
};
}
function parseExpression(
column: Record<string, unknown>,
name: string,
id: string,
): ExpressionConfig {
const dtype = readString(column.dtype);
const normalized = EXPRESSION_DTYPES.includes(dtype as ExpressionDtype)
? (dtype as ExpressionDtype)
: "str";
return {
id,
kind: "expression",
name,
expr: readString(column.expr) ?? "",
dtype: normalized,
};
}
type ColumnParser = (
column: Record<string, unknown>,
name: string,
id: string,
errors: string[],
) => NodeConfig | null;
const COLUMN_PARSERS: Record<string, ColumnParser> = {
sampler: (column, name, id, errors) => parseSampler(column, name, id, errors),
expression: (column, name, id) => parseExpression(column, name, id),
"llm-text": (column, name, id) => parseLlm(column, name, id),
"llm-structured": (column, name, id) => parseLlm(column, name, id),
"llm-code": (column, name, id) => parseLlm(column, name, id),
};
function parseColumn(
column: Record<string, unknown>,
id: string,
errors: string[],
): NodeConfig | null {
const name = readString(column.name);
if (!name) {
errors.push("Column missing name.");
return null;
}
const columnType = readString(column.column_type);
const parser = columnType ? COLUMN_PARSERS[columnType] : null;
if (parser) {
return parser(column, name, id, errors);
}
errors.push(`Column ${name}: unsupported column_type.`);
return null;
}
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);
}
function buildEdges(
configs: NodeConfig[],
nameToId: Map<string, string>,
uiEdges: Array<{ from: string; to: string }> | null,
): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const addEdgeByName = (from: string, to: string) => {
const sourceId = nameToId.get(from);
const targetId = nameToId.get(to);
if (!(sourceId && targetId)) {
return;
}
const key = `${sourceId}-${targetId}`;
if (seen.has(key)) {
return;
}
seen.add(key);
edges.push({ id: `e-${key}`, source: sourceId, target: targetId });
};
if (uiEdges && uiEdges.length > 0) {
for (const edge of uiEdges) {
addEdgeByName(edge.from, edge.to);
}
} else {
for (const config of configs) {
if (config.kind === "llm") {
for (const ref of extractRefs(config.prompt ?? "")) {
addEdgeByName(ref, config.name);
}
for (const ref of extractRefs(config.system_prompt ?? "")) {
addEdgeByName(ref, config.name);
}
}
if (config.kind === "expression") {
for (const ref of extractRefs(config.expr)) {
addEdgeByName(ref, config.name);
}
}
if (
config.kind === "sampler" &&
config.sampler_type === "subcategory" &&
config.subcategory_parent
) {
addEdgeByName(config.subcategory_parent, config.name);
}
}
}
return edges;
}
function buildNodes(
configs: NodeConfig[],
positions: Map<string, { x: number; y: number }>,
): CanvasNode[] {
return configs.map((config, index) => {
const position =
positions.get(config.name) ?? ({ x: 0, y: index * 140 } as const);
return {
id: config.id,
type: "builder",
position,
data: nodeDataFromConfig(config),
};
});
}
function parseUi(
ui: UiInput | null,
): {
positions: Map<string, { x: number; y: number }>;
edges: Array<{ from: string; to: string }> | null;
} {
const positions = new Map<string, { x: number; y: number }>();
const edges: Array<{ from: string; to: string }> = [];
if (ui && Array.isArray(ui.nodes)) {
for (const node of ui.nodes) {
if (isRecord(node)) {
const id = readString(node.id);
const x = typeof node.x === "number" ? node.x : null;
const y = typeof node.y === "number" ? node.y : null;
if (id && x !== null && y !== null) {
positions.set(id, { x, y });
}
}
}
}
if (ui && Array.isArray(ui.edges)) {
for (const edge of ui.edges) {
if (isRecord(edge)) {
const from = readString(edge.from);
const to = readString(edge.to);
if (from && to) {
edges.push({ from, to });
}
}
}
}
return { positions, edges: edges.length > 0 ? edges : null };
}
export function importCanvasPayload(input: string): ImportResult {
const parsed = parseJson(input);
if (!parsed.data || !isRecord(parsed.data)) {
return {
errors: [parsed.error ?? "Invalid JSON payload."],
snapshot: null,
};
}
const recipe = (isRecord(parsed.data.recipe)
? parsed.data.recipe
: parsed.data) as RecipeInput;
const ui = isRecord(parsed.data.ui) ? (parsed.data.ui as UiInput) : null;
if (!Array.isArray(recipe.columns)) {
return { errors: ["Recipe must include columns."], snapshot: null };
}
const errors: string[] = [];
const configs: NodeConfig[] = [];
const nameToId = new Map<string, string>();
recipe.columns.forEach((column, index) => {
if (!isRecord(column)) {
errors.push(`Column ${index + 1}: invalid object.`);
return;
}
const id = `n${index + 1}`;
const config = parseColumn(column, id, errors);
if (!config) {
return;
}
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
return;
}
nameToId.set(config.name, config.id);
configs.push(config);
});
if (errors.length > 0) {
return { errors, snapshot: null };
}
const { positions, edges: uiEdges } = parseUi(ui);
const nodes = buildNodes(configs, positions);
const edges = buildEdges(configs, nameToId, uiEdges);
const maxY = nodes.reduce(
(acc, node) => Math.max(acc, node.position.y),
0,
);
return {
errors: [],
snapshot: {
configs: Object.fromEntries(configs.map((config) => [config.id, config])),
nodes,
edges,
nextId: configs.length + 1,
nextY: maxY + 140,
},
};
}