feat: add builders and components for LLM configuration in Recipe Studio and refactor for readability, preparing for draft

This commit is contained in:
Shine1i 2026-02-12 04:25:15 +01:00
commit e145a72adb
31 changed files with 2644 additions and 889 deletions

View file

@ -4,9 +4,9 @@ import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const EditRecipeEditorPage = lazy(() =>
const EditRecipePage = lazy(() =>
import("@/features/data-recipes").then((m) => ({
default: m.EditRecipeEditorPage,
default: m.EditRecipePage,
})),
);
@ -19,5 +19,5 @@ export const Route = createRoute({
function DataRecipeEditorRoute(): ReactElement {
const { recipeId } = Route.useParams();
return <EditRecipeEditorPage recipeId={recipeId} />;
return <EditRecipePage recipeId={recipeId} />;
}

View file

@ -22,7 +22,11 @@ function createEmptyPayload(): RecipePayload {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: [],
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: [],
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: [],
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: [],
columns: [],
processors: [],
},

View file

@ -1,2 +1,2 @@
export { DataRecipesPage } from "./pages/data-recipes-page";
export { EditRecipeEditorPage } from "./pages/edit-recipe-page";
export { EditRecipePage } from "./pages/edit-recipe-page";

View file

@ -46,21 +46,20 @@ export function DataRecipesPage(): ReactElement {
const recipes = useRecipes();
const [creatingRecipe, setCreatingRecipe] = useState(false);
function openNewRecipe(): void {
async function openNewRecipe(): Promise<void> {
if (creatingRecipe) {
return;
}
setCreatingRecipe(true);
void createRecipeDraft()
.then((recipe) => {
void navigate({
to: "/data-recipes/$recipeId",
params: { recipeId: recipe.id },
});
})
.finally(() => {
setCreatingRecipe(false);
try {
const recipe = await createRecipeDraft();
await navigate({
to: "/data-recipes/$recipeId",
params: { recipeId: recipe.id },
});
} finally {
setCreatingRecipe(false);
}
}
function openRecipe(recipeId: string): void {
@ -84,7 +83,13 @@ export function DataRecipesPage(): ReactElement {
Create and manage local recipe workflows.
</p>
</div>
<Button type="button" onClick={openNewRecipe} disabled={creatingRecipe}>
<Button
type="button"
onClick={() => {
void openNewRecipe();
}}
disabled={creatingRecipe}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
New Recipe
</Button>
@ -102,7 +107,13 @@ export function DataRecipesPage(): ReactElement {
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={openNewRecipe} disabled={creatingRecipe}>
<Button
type="button"
onClick={() => {
void openNewRecipe();
}}
disabled={creatingRecipe}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
Create Recipe
</Button>

View file

@ -39,7 +39,7 @@ function RecipeLoadState({
);
}
export function EditRecipeEditorPage({ recipeId }: EditRecipePageProps): ReactElement {
export function EditRecipePage({ recipeId }: EditRecipePageProps): ReactElement {
const navigate = useNavigate();
const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });

View file

@ -3,15 +3,56 @@ const DEFAULT_BASE = "";
export const DATA_DESIGNER_API_BASE =
import.meta.env.VITE_DATA_DESIGNER_API ?? DEFAULT_BASE;
type PreviewResponse = {
export type PreviewResponse = {
dataset?: unknown[];
processorArtifacts?: Record<string, unknown>;
// biome-ignore lint/style/useNamingConvention: api schema
processor_artifacts?: Record<string, unknown>;
};
export async function previewRecipe(
payload: unknown,
): Promise<PreviewResponse> {
const response = await fetch(`${DATA_DESIGNER_API_BASE}/preview`, {
export type ValidateError = {
message: string;
path?: string | null;
code?: string | null;
};
export type ValidateResponse = {
valid: boolean;
errors: ValidateError[];
// biome-ignore lint/style/useNamingConvention: api schema
raw_detail?: string | null;
};
export type ToolsResponse = {
// biome-ignore lint/style/useNamingConvention: api schema
tools_by_provider: Record<string, string[]>;
tools: string[];
};
async function parseErrorResponse(response: Response): Promise<string> {
const text = (await response.text()).trim();
if (!text) {
return "Request failed.";
}
try {
const parsed = JSON.parse(text) as {
detail?: string;
message?: string;
// biome-ignore lint/style/useNamingConvention: api schema
raw_detail?: string;
};
return (
parsed.detail ??
parsed.message ??
parsed.raw_detail ??
text
);
} catch {
return text;
}
}
async function postJson<T>(path: string, payload: unknown): Promise<T> {
const response = await fetch(`${DATA_DESIGNER_API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -20,9 +61,22 @@ export async function previewRecipe(
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Preview request failed.");
throw new Error(await parseErrorResponse(response));
}
return response.json();
}
export async function previewRecipe(payload: unknown): Promise<PreviewResponse> {
return postJson<PreviewResponse>("/preview", payload);
}
export async function validateRecipe(
payload: unknown,
): Promise<ValidateResponse> {
return postJson<ValidateResponse>("/validate", payload);
}
export async function listRecipeTools(payload: unknown): Promise<ToolsResponse> {
return postJson<ToolsResponse>("/tools", payload);
}

View file

@ -1,67 +1,98 @@
import { Button } from "@/components/ui/button";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type KeyboardEvent, type ReactElement, useState } from "react";
type ChipInputProps = {
values: string[];
onAdd: (value: string) => void;
onRemove: (index: number) => void;
placeholder?: string;
};
export function ChipInput({
values,
onAdd,
onRemove,
placeholder = "Type and press Enter",
}: ChipInputProps): ReactElement {
const [draft, setDraft] = useState("");
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
const trimmed = draft.trim();
if (trimmed) {
onAdd(trimmed);
setDraft("");
}
}
if (event.key === "Backspace" && !draft && values.length > 0) {
onRemove(values.length - 1);
}
};
return (
<div className="bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-4xl border bg-clip-padding px-1.5 py-1.5 text-sm transition-colors focus-within:ring-[3px]">
{values.map((value, index) => (
<span
key={`${value}-${index}`}
className="bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-4xl pr-0 pl-2 text-xs font-medium whitespace-nowrap"
>
{value}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 opacity-50 hover:opacity-100"
onClick={() => onRemove(index)}
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="pointer-events-none"
/>
</Button>
</span>
))}
<input
className="nodrag min-w-16 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder={values.length === 0 ? placeholder : ""}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
);
}
import { Button } from "@/components/ui/button";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type KeyboardEvent, type ReactElement, useId, useMemo, useState } from "react";
type ChipInputProps = {
values: string[];
onAdd: (value: string) => void;
onRemove: (index: number) => void;
placeholder?: string;
suggestions?: string[];
};
export function ChipInput({
values,
onAdd,
onRemove,
placeholder = "Type and press Enter",
suggestions,
}: ChipInputProps): ReactElement {
const [draft, setDraft] = useState("");
const listId = useId();
const suggestionSet = useMemo(
() => new Set((suggestions ?? []).map((value) => value.trim())),
[suggestions],
);
function addValue(rawValue: string, allowAny: boolean): void {
const trimmed = rawValue.trim();
if (!trimmed) {
return;
}
if (!allowAny && !suggestionSet.has(trimmed)) {
return;
}
onAdd(trimmed);
setDraft("");
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
addValue(draft, true);
}
if (event.key === "Backspace" && !draft && values.length > 0) {
onRemove(values.length - 1);
}
};
function handleChange(nextDraft: string): void {
setDraft(nextDraft);
if (suggestionSet.has(nextDraft.trim())) {
addValue(nextDraft, false);
}
}
return (
<div className="bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-4xl border bg-clip-padding px-1.5 py-1.5 text-sm transition-colors focus-within:ring-[3px]">
{values.map((value, index) => (
<span
key={`${value}-${index}`}
className="bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-4xl pr-0 pl-2 text-xs font-medium whitespace-nowrap"
>
{value}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 opacity-50 hover:opacity-100"
onClick={() => onRemove(index)}
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="pointer-events-none"
/>
</Button>
</span>
))}
<input
className="nodrag min-w-16 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder={values.length === 0 ? placeholder : ""}
value={draft}
list={suggestions && suggestions.length > 0 ? listId : undefined}
onChange={(event) => handleChange(event.target.value)}
onBlur={() => addValue(draft, false)}
onKeyDown={handleKeyDown}
/>
{suggestions && suggestions.length > 0 && (
<datalist id={listId}>
{suggestions.map((value) => (
<option key={value} value={value} />
))}
</datalist>
)}
</div>
);
}

View file

@ -0,0 +1,178 @@
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, type RefObject } from "react";
import type { LlmConfig } from "../../types";
import { AvailableVariables } from "../shared/available-variables";
import { NameField } from "../shared/name-field";
const CODE_LANG_OPTIONS = [
"python",
"javascript",
"typescript",
"java",
"kotlin",
"go",
"rust",
"ruby",
"scala",
"swift",
"sql:sqlite",
"sql:postgres",
"sql:mysql",
"sql:tsql",
"sql:bigquery",
"sql:ansi",
];
type LlmGeneralTabProps = {
config: LlmConfig;
modelConfigAliases: string[];
modelAliasAnchorRef: RefObject<HTMLDivElement | null>;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
export function LlmGeneralTab({
config,
modelConfigAliases,
modelAliasAnchorRef,
onUpdate,
}: LlmGeneralTabProps): ReactElement {
const modelAliasId = `${config.id}-model-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;
const outputFormatId = `${config.id}-output-format`;
const systemPromptId = `${config.id}-system-prompt`;
return (
<div className="space-y-4">
<AvailableVariables configId={config.id} />
<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={modelAliasId}
>
Model alias
</label>
<div ref={modelAliasAnchorRef}>
<Combobox
items={modelConfigAliases}
filteredItems={modelConfigAliases}
filter={null}
value={config.model_alias || null}
onValueChange={(value) => onUpdate({ model_alias: value ?? "" })}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
id={modelAliasId}
className="nodrag w-full"
placeholder="Pick model alias or type"
onBlur={(event) => {
const inputValue = event.target.value;
if (inputValue !== config.model_alias) {
onUpdate({ model_alias: inputValue });
}
}}
/>
<ComboboxContent anchor={modelAliasAnchorRef}>
<ComboboxEmpty>No model configs found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</div>
{config.llm_type === "code" && (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={codeLangId}
>
Code language
</label>
<Select
value={config.code_lang ?? "python"}
onValueChange={(value) => onUpdate({ code_lang: value })}
>
<SelectTrigger className="nodrag w-full" id={codeLangId}>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{CODE_LANG_OPTIONS.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={promptId}
>
Prompt
</label>
<Textarea
id={promptId}
className="corner-squircle nodrag"
value={config.prompt}
onChange={(event) => onUpdate({ prompt: event.target.value })}
/>
</div>
{config.llm_type === "structured" && (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={outputFormatId}
>
Output format (JSON schema)
</label>
<Textarea
id={outputFormatId}
className="corner-squircle nodrag"
value={config.output_format ?? ""}
onChange={(event) =>
onUpdate({ output_format: event.target.value })
}
/>
</div>
)}
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={systemPromptId}
>
System prompt (optional)
</label>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag"
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
/>
</div>
</div>
);
}

View file

@ -1,43 +1,14 @@
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { LlmConfig, Score } from "../../types";
import { AvailableVariables } from "../shared/available-variables";
import { NameField } from "../shared/name-field";
const CODE_LANG_OPTIONS = [
"python",
"javascript",
"typescript",
"java",
"kotlin",
"go",
"rust",
"ruby",
"scala",
"swift",
"sql:sqlite",
"sql:postgres",
"sql:mysql",
"sql:tsql",
"sql:bigquery",
"sql:ansi",
];
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { type ReactElement, useRef } from "react";
import type { LlmConfig } from "../../types";
import { LlmGeneralTab } from "./general-tab";
import { LlmMcpToolsTab } from "./mcp-tools-tab";
import { LlmScoresTab } from "./scores-tab";
type LlmDialogProps = {
config: LlmConfig;
@ -50,197 +21,31 @@ export function LlmDialog({
modelConfigAliases,
onUpdate,
}: LlmDialogProps): ReactElement {
const modelAliasId = `${config.id}-model-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;
const outputFormatId = `${config.id}-output-format`;
const systemPromptId = `${config.id}-system-prompt`;
const modelAliasAnchorRef = useRef<HTMLDivElement>(null);
const [aliasInput, setAliasInput] = useState(config.model_alias);
useEffect(() => {
setAliasInput(config.model_alias);
}, [config.model_alias]);
const scores = config.scores ?? [];
const updateField = <K extends keyof LlmConfig>(
key: K,
value: LlmConfig[K],
) => {
onUpdate({ [key]: value } as Partial<LlmConfig>);
};
const updateScores = (next: Score[]) => updateField("scores", next);
const removeScore = (index: number) => {
updateScores(scores.filter((_, i) => i !== index));
};
const addScore = () => {
updateScores([
...scores,
{
name: "",
description: "",
options: [
{ value: "1", description: "" },
{ value: "5", description: "" },
],
},
]);
};
return (
<div className="space-y-4">
<AvailableVariables configId={config.id} />
<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={modelAliasId}
>
Model alias
</label>
<div ref={modelAliasAnchorRef}>
<Combobox
items={modelConfigAliases}
filteredItems={modelConfigAliases}
filter={null}
value={config.model_alias || null}
onValueChange={(value) => updateField("model_alias", value ?? "")}
onInputValueChange={setAliasInput}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
id={modelAliasId}
className="nodrag w-full"
placeholder="Pick model alias or type"
onBlur={() => {
if (aliasInput !== config.model_alias) {
updateField("model_alias", aliasInput);
}
}}
/>
<ComboboxContent anchor={modelAliasAnchorRef}>
<ComboboxEmpty>No model configs found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<p className="text-xs text-muted-foreground">
Pick a model config alias. Matching node link becomes semantic.
</p>
</div>
{config.llm_type === "code" && (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={codeLangId}
>
Code language
</label>
<Select
value={config.code_lang ?? "python"}
onValueChange={(value) => updateField("code_lang", value)}
>
<SelectTrigger className="nodrag w-full" id={codeLangId}>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{CODE_LANG_OPTIONS.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={promptId}
>
Prompt
</label>
<Textarea
id={promptId}
className="corner-squircle nodrag"
value={config.prompt}
onChange={(event) => updateField("prompt", event.target.value)}
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger>
{config.llm_type === "judge" && <TabsTrigger value="scores">Scores</TabsTrigger>}
<TabsTrigger value="tools">MCPs / Tools</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-3">
<LlmGeneralTab
config={config}
modelConfigAliases={modelConfigAliases}
modelAliasAnchorRef={modelAliasAnchorRef}
onUpdate={onUpdate}
/>
</div>
</TabsContent>
{config.llm_type === "judge" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Scorers
</p>
<Button type="button" size="xs" variant="outline" onClick={addScore}>
Add scorer block
</Button>
</div>
{scores.length === 0 && (
<p className="text-xs text-muted-foreground">
Add scorer blocks. Each block spawns on graph and connects to this judge node.
</p>
)}
{scores.map((score, index) => (
<div key={`${config.id}-score-${index}`} className="flex items-center justify-between rounded-xl corner-squircle border border-border/60 px-3 py-2">
<div>
<p className="text-xs font-semibold text-foreground">
{score.name.trim() || `Scorer ${index + 1}`}
</p>
<p className="text-[11px] text-muted-foreground">
{(score.options ?? []).length} options
</p>
</div>
<Button type="button" size="xs" variant="ghost" onClick={() => removeScore(index)}>
Remove
</Button>
</div>
))}
</div>
<TabsContent value="scores" className="pt-3">
<LlmScoresTab config={config} onUpdate={onUpdate} />
</TabsContent>
)}
{config.llm_type === "structured" && (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={outputFormatId}
>
Output format (JSON schema)
</label>
<Textarea
id={outputFormatId}
className="corner-squircle nodrag"
value={config.output_format ?? ""}
onChange={(event) =>
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">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={systemPromptId}
>
System prompt (optional)
</label>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag"
value={config.system_prompt}
onChange={(event) => updateField("system_prompt", event.target.value)}
/>
</div>
</div>
<TabsContent value="tools" className="pt-3">
<LlmMcpToolsTab config={config} onUpdate={onUpdate} />
</TabsContent>
</Tabs>
);
}

View file

@ -0,0 +1,315 @@
import { type ReactElement, useMemo, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { listRecipeTools } from "../../api";
import type {
LlmConfig,
LlmMcpProviderConfig,
LlmToolConfig,
McpEnvVar,
} from "../../types";
import { toastError, toastSuccess } from "@/shared/toast";
import { McpProvidersSection } from "./mcp-tools/mcp-providers-section";
import {
createMcpProviderId,
createToolConfigId,
isProviderReadyForToolFetch,
resolveLlmToolAlias,
toApiProvider,
} from "./mcp-tools/helpers";
import { ToolConfigsSection } from "./mcp-tools/tool-configs-section";
type LlmMcpToolsTabProps = {
config: LlmConfig;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
const EMPTY_MCP_PROVIDERS: LlmMcpProviderConfig[] = [];
const EMPTY_TOOL_CONFIGS: LlmToolConfig[] = [];
function uniqueTrimmed(values: string[]): string[] {
return Array.from(
new Set(values.map((value) => value.trim()).filter(Boolean)),
);
}
export function LlmMcpToolsTab({
config,
onUpdate,
}: LlmMcpToolsTabProps): ReactElement {
const providers = config.mcp_providers ?? EMPTY_MCP_PROVIDERS;
const toolConfigs = config.tool_configs ?? EMPTY_TOOL_CONFIGS;
const [loadingTools, setLoadingTools] = useState(false);
const [toolsByProvider, setToolsByProvider] = useState<Record<string, string[]>>(
{},
);
function updateProviders(nextProviders: LlmMcpProviderConfig[]): void {
onUpdate({ mcp_providers: nextProviders });
}
function updateToolConfigs(nextToolConfigs: LlmToolConfig[]): void {
onUpdate({
tool_configs: nextToolConfigs,
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: resolveLlmToolAlias(nextToolConfigs, config.tool_alias),
});
}
function updateProviderAt(
index: number,
patch: Partial<LlmMcpProviderConfig>,
): void {
updateProviders(
providers.map((provider, currentIndex) =>
currentIndex === index ? { ...provider, ...patch } : provider,
),
);
}
function mutateProviderAt(
index: number,
mapProvider: (provider: LlmMcpProviderConfig) => Partial<LlmMcpProviderConfig>,
): void {
const provider = providers[index];
if (!provider) {
return;
}
updateProviderAt(index, mapProvider(provider));
}
function removeProvider(index: number): void {
updateProviders(
providers.filter((_, currentIndex) => currentIndex !== index),
);
}
function addProvider(): void {
updateProviders([
...providers,
{
id: createMcpProviderId(config.id, providers.length),
name: "",
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: "stdio",
command: "",
args: [""],
env: [{ key: "", value: "" }],
endpoint: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "",
},
]);
}
function addProviderArg(providerIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
args: [...(provider.args ?? []), ""],
}));
}
function updateProviderArg(
providerIndex: number,
argIndex: number,
value: string,
): void {
mutateProviderAt(providerIndex, (provider) => {
const nextArgs = [...(provider.args ?? [])];
nextArgs[argIndex] = value;
return { args: nextArgs };
});
}
function removeProviderArg(providerIndex: number, argIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
args: (provider.args ?? []).filter(
(_, currentIndex) => currentIndex !== argIndex,
),
}));
}
function addProviderEnv(providerIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
env: [...(provider.env ?? []), { key: "", value: "" }],
}));
}
function updateProviderEnv(
providerIndex: number,
envIndex: number,
patch: Partial<McpEnvVar>,
): void {
mutateProviderAt(providerIndex, (provider) => ({
env: (provider.env ?? []).map((item, currentIndex) =>
currentIndex === envIndex ? { ...item, ...patch } : item,
),
}));
}
function removeProviderEnv(providerIndex: number, envIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
env: (provider.env ?? []).filter(
(_, currentIndex) => currentIndex !== envIndex,
),
}));
}
function updateToolConfigAt(
index: number,
patch: Partial<LlmToolConfig>,
): void {
updateToolConfigs(
toolConfigs.map((toolConfig, currentIndex) =>
currentIndex === index ? { ...toolConfig, ...patch } : toolConfig,
),
);
}
function addToolConfig(): void {
updateToolConfigs([
...toolConfigs,
{
id: createToolConfigId(config.id, toolConfigs.length),
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: "",
providers: [],
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [],
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: "5",
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: "",
},
]);
}
function removeToolConfig(index: number): void {
updateToolConfigs(
toolConfigs.filter((_, currentIndex) => currentIndex !== index),
);
}
async function loadToolNames(): Promise<void> {
const apiProviders = providers
.filter(isProviderReadyForToolFetch)
.map(toApiProvider)
.filter((provider) => Boolean(provider.name));
if (apiProviders.length === 0) {
toastError(
"No MCP servers configured",
"Add server name and command/endpoint first.",
);
return;
}
setLoadingTools(true);
try {
const response = await listRecipeTools({
recipe: {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: [],
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: apiProviders,
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: [],
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: [],
columns: [],
processors: [],
},
});
// biome-ignore lint/style/useNamingConvention: api schema
setToolsByProvider(response.tools_by_provider ?? {});
toastSuccess("Fetched MCP tools");
} catch (error) {
const message = error instanceof Error ? error.message : "Tool fetch failed.";
toastError("Failed to fetch tools", message);
} finally {
setLoadingTools(false);
}
}
const providerNameSuggestions = useMemo(
() => uniqueTrimmed(providers.map((provider) => provider.name)),
[providers],
);
const toolAliasOptions = useMemo(
() => uniqueTrimmed(toolConfigs.map((item) => item.tool_alias)),
[toolConfigs],
);
const activeToolAlias = useMemo(() => {
const currentAlias = config.tool_alias?.trim() ?? "";
if (toolAliasOptions.includes(currentAlias)) {
return currentAlias;
}
return toolAliasOptions[0] ?? "";
}, [config.tool_alias, toolAliasOptions]);
return (
<div className="space-y-5">
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Active tool alias
</label>
{toolAliasOptions.length > 0 ? (
<Select
value={activeToolAlias}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: value,
})
}
>
<SelectTrigger className="nodrag w-full">
<SelectValue placeholder="Select active tool alias" />
</SelectTrigger>
<SelectContent>
{toolAliasOptions.map((alias) => (
<SelectItem key={alias} value={alias}>
{alias}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-muted-foreground">
Add tool config alias first.
</p>
)}
</div>
<ToolConfigsSection
toolConfigs={toolConfigs}
providerNameSuggestions={providerNameSuggestions}
toolsByProvider={toolsByProvider}
loadingTools={loadingTools}
onFetchTools={() => {
void loadToolNames();
}}
onAddToolConfig={addToolConfig}
onUpdateToolConfig={updateToolConfigAt}
onRemoveToolConfig={removeToolConfig}
/>
<McpProvidersSection
providers={providers}
onAddProvider={addProvider}
onUpdateProviderAt={updateProviderAt}
onRemoveProvider={removeProvider}
onAddProviderArg={addProviderArg}
onUpdateProviderArg={updateProviderArg}
onRemoveProviderArg={removeProviderArg}
onAddProviderEnv={addProviderEnv}
onUpdateProviderEnv={updateProviderEnv}
onRemoveProviderEnv={removeProviderEnv}
/>
</div>
);
}

View file

@ -0,0 +1,87 @@
import type { LlmMcpProviderConfig, LlmToolConfig } from "../../../types";
export function createMcpProviderId(prefix: string, index: number): string {
return `${prefix}-mcp-${Date.now()}-${index + 1}`;
}
export function createToolConfigId(prefix: string, index: number): string {
return `${prefix}-tool-${Date.now()}-${index + 1}`;
}
export function addUnique(items: string[], value: string): string[] {
const trimmed = value.trim();
if (!trimmed || items.includes(trimmed)) {
return items;
}
return [...items, trimmed];
}
export function toApiProvider(
provider: LlmMcpProviderConfig,
): Record<string, unknown> {
if (provider.provider_type === "stdio") {
const env = Object.fromEntries(
(provider.env ?? [])
.map((item) => [item.key.trim(), item.value.trim()] as const)
.filter(([key, value]) => key && value),
);
return {
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "stdio",
name: provider.name.trim(),
command: provider.command?.trim() ?? "",
args: (provider.args ?? []).map((value) => value.trim()).filter(Boolean),
env,
};
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "streamable_http",
name: provider.name.trim(),
endpoint: provider.endpoint?.trim() ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: provider.api_key?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: provider.api_key_env?.trim() || undefined,
};
}
export function collectToolSuggestions(
providerNames: string[],
toolsByProvider: Record<string, string[]>,
): string[] {
return Array.from(
new Set(
providerNames.flatMap((providerName) => {
return toolsByProvider[providerName.trim()] ?? [];
}),
),
);
}
export function isProviderReadyForToolFetch(
provider: LlmMcpProviderConfig,
): boolean {
const hasName = provider.name.trim().length > 0;
if (!hasName) {
return false;
}
if (provider.provider_type === "stdio") {
return (provider.command?.trim().length ?? 0) > 0;
}
return (provider.endpoint?.trim().length ?? 0) > 0;
}
export function resolveLlmToolAlias(
toolConfigs: LlmToolConfig[],
previousAlias: string | undefined,
): string {
const toolAliases = toolConfigs
.map((item) => item.tool_alias.trim())
.filter(Boolean);
const currentAlias = previousAlias?.trim() ?? "";
if (currentAlias && toolAliases.includes(currentAlias)) {
return currentAlias;
}
return toolAliases[0] ?? "";
}

View file

@ -0,0 +1,263 @@
import { Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { LlmMcpProviderConfig, McpEnvVar } from "../../../types";
type McpProvidersSectionProps = {
providers: LlmMcpProviderConfig[];
onAddProvider: () => void;
onUpdateProviderAt: (
index: number,
patch: Partial<LlmMcpProviderConfig>,
) => void;
onRemoveProvider: (index: number) => void;
onAddProviderArg: (providerIndex: number) => void;
onUpdateProviderArg: (
providerIndex: number,
argIndex: number,
value: string,
) => void;
onRemoveProviderArg: (providerIndex: number, argIndex: number) => void;
onAddProviderEnv: (providerIndex: number) => void;
onUpdateProviderEnv: (
providerIndex: number,
envIndex: number,
patch: Partial<McpEnvVar>,
) => void;
onRemoveProviderEnv: (providerIndex: number, envIndex: number) => void;
};
export function McpProvidersSection({
providers,
onAddProvider,
onUpdateProviderAt,
onRemoveProvider,
onAddProviderArg,
onUpdateProviderArg,
onRemoveProviderArg,
onAddProviderEnv,
onUpdateProviderEnv,
onRemoveProviderEnv,
}: McpProvidersSectionProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-muted-foreground">
MCP servers
</p>
<Button type="button" size="xs" variant="outline" onClick={onAddProvider}>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add MCP server
</Button>
</div>
{providers.length === 0 && (
<p className="text-xs text-muted-foreground">
Add MCP servers to be referenced by tool config providers.
</p>
)}
{providers.map((provider, providerIndex) => {
const args = provider.args && provider.args.length > 0 ? provider.args : [""];
const envVars =
provider.env && provider.env.length > 0
? provider.env
: [{ key: "", value: "" }];
return (
<div
key={provider.id}
className="space-y-3 border-b border-border/40 pb-4 last:border-b-0"
>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Name
</label>
<Input
value={provider.name}
placeholder="MCP server name"
onChange={(event) =>
onUpdateProviderAt(providerIndex, { name: event.target.value })
}
/>
</div>
<Tabs
value={provider.provider_type}
onValueChange={(value) =>
onUpdateProviderAt(providerIndex, {
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: value === "stdio" ? "stdio" : "streamable_http",
})
}
>
<TabsList className="w-full">
<TabsTrigger value="stdio">STDIO</TabsTrigger>
<TabsTrigger value="streamable_http">Streamable HTTP</TabsTrigger>
</TabsList>
</Tabs>
{provider.provider_type === "stdio" ? (
<div className="space-y-3">
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Command to launch
</label>
<Input
value={provider.command ?? ""}
placeholder="npx"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
command: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Arguments
</p>
{args.map((arg, argIndex) => (
<div key={`${provider.id}-arg-${argIndex}`} className="flex gap-2">
<Input
value={arg}
placeholder={argIndex === 0 ? "-y" : "argument"}
onChange={(event) =>
onUpdateProviderArg(providerIndex, argIndex, event.target.value)
}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => onRemoveProviderArg(providerIndex, argIndex)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => onAddProviderArg(providerIndex)}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
</Button>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Environment variables
</p>
{envVars.map((item, envIndex) => (
<div
key={`${provider.id}-env-${envIndex}`}
className="grid grid-cols-[1fr_1fr_auto] gap-2"
>
<Input
value={item.key}
placeholder="Key"
onChange={(event) =>
onUpdateProviderEnv(providerIndex, envIndex, {
key: event.target.value,
})
}
/>
<Input
value={item.value}
placeholder="Value"
onChange={(event) =>
onUpdateProviderEnv(providerIndex, envIndex, {
value: event.target.value,
})
}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => onRemoveProviderEnv(providerIndex, envIndex)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => onAddProviderEnv(providerIndex)}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
</Button>
</div>
</div>
) : (
<div className="space-y-2">
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Endpoint
</label>
<Input
value={provider.endpoint ?? ""}
placeholder="https://example.com/mcp"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
endpoint: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
API key env (optional)
</label>
<Input
value={provider.api_key_env ?? ""}
placeholder="MCP_API_KEY"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
API key (optional)
</label>
<Input
value={provider.api_key ?? ""}
placeholder="api key"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
// biome-ignore lint/style/useNamingConvention: api schema
api_key: event.target.value,
})
}
/>
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => onRemoveProvider(providerIndex)}
>
Remove
</Button>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,175 @@
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ChipInput } from "../../../components/chip-input";
import type { LlmToolConfig } from "../../../types";
import { addUnique, collectToolSuggestions } from "./helpers";
type ToolConfigsSectionProps = {
toolConfigs: LlmToolConfig[];
providerNameSuggestions: string[];
toolsByProvider: Record<string, string[]>;
loadingTools: boolean;
onFetchTools: () => void;
onAddToolConfig: () => void;
onUpdateToolConfig: (index: number, patch: Partial<LlmToolConfig>) => void;
onRemoveToolConfig: (index: number) => void;
};
export function ToolConfigsSection({
toolConfigs,
providerNameSuggestions,
toolsByProvider,
loadingTools,
onFetchTools,
onAddToolConfig,
onUpdateToolConfig,
onRemoveToolConfig,
}: ToolConfigsSectionProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Tool configs
</p>
<div className="flex items-center gap-2">
<Button
type="button"
size="xs"
variant="outline"
disabled={loadingTools}
onClick={onFetchTools}
>
{loadingTools ? "Loading..." : "Fetch MCP tools"}
</Button>
<Button type="button" size="xs" variant="outline" onClick={onAddToolConfig}>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add tool config
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Define aliases/providers here. Active alias is selected above.
</p>
{toolConfigs.length === 0 && (
<p className="text-xs text-muted-foreground">
Add at least one tool config to map alias to providers.
</p>
)}
{toolConfigs.map((toolConfig, index) => (
<div
key={toolConfig.id}
className="space-y-3 border-b border-border/40 pb-4 last:border-b-0"
>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Tool alias
</label>
<Input
value={toolConfig.tool_alias}
placeholder="context7_tools"
onChange={(event) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Providers
</label>
<ChipInput
values={toolConfig.providers}
suggestions={providerNameSuggestions}
onAdd={(value) =>
onUpdateToolConfig(index, {
providers: addUnique(toolConfig.providers, value),
})
}
onRemove={(providerIndex) =>
onUpdateToolConfig(index, {
providers: toolConfig.providers.filter(
(_, currentIndex) => currentIndex !== providerIndex,
),
})
}
placeholder="Type provider name and press Enter"
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Allow tools (optional)
</label>
<ChipInput
// biome-ignore lint/style/useNamingConvention: api schema
values={toolConfig.allow_tools ?? []}
suggestions={collectToolSuggestions(toolConfig.providers, toolsByProvider)}
onAdd={(value) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: addUnique(toolConfig.allow_tools ?? [], value),
})
}
onRemove={(toolIndex) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: (toolConfig.allow_tools ?? []).filter(
(_, currentIndex) => currentIndex !== toolIndex,
),
})
}
placeholder="Type tool name and press Enter"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Max turns
</label>
<Input
value={toolConfig.max_tool_call_turns ?? ""}
onChange={(event) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Timeout sec
</label>
<Input
value={toolConfig.timeout_sec ?? ""}
onChange={(event) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: event.target.value,
})
}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => onRemoveToolConfig(index)}
>
Remove
</Button>
</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,79 @@
import { Button } from "@/components/ui/button";
import { type ReactElement } from "react";
import type { LlmConfig, Score } from "../../types";
type LlmScoresTabProps = {
config: LlmConfig;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
export function LlmScoresTab({
config,
onUpdate,
}: LlmScoresTabProps): ReactElement {
const scores = config.scores ?? [];
function updateScores(nextScores: Score[]): void {
onUpdate({ scores: nextScores });
}
function removeScore(index: number): void {
updateScores(scores.filter((_, currentIndex) => currentIndex !== index));
}
function addScore(): void {
updateScores([
...scores,
{
name: "",
description: "",
options: [
{ value: "1", description: "" },
{ value: "5", description: "" },
],
},
]);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Scorers
</p>
<Button type="button" size="xs" variant="outline" onClick={addScore}>
Add scorer block
</Button>
</div>
{scores.length === 0 && (
<p className="text-xs text-muted-foreground">
Add scorer blocks. Each block spawns on graph and connects to this judge
node.
</p>
)}
{scores.map((score, index) => (
<div
key={`${config.id}-score-${index}`}
className="flex items-center justify-between rounded-xl corner-squircle border border-border/60 px-3 py-2"
>
<div>
<p className="text-xs font-semibold text-foreground">
{score.name.trim() || `Scorer ${index + 1}`}
</p>
<p className="text-[11px] text-muted-foreground">
{(score.options ?? []).length} options
</p>
</div>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeScore(index)}
>
Remove
</Button>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,129 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { type ReactElement } from "react";
type PreviewDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
rows: number;
onRowsChange: (rows: number) => void;
loading: boolean;
errors: string[];
summary: {
totalColumns: number;
llmColumns: number;
samplerColumns: number;
expressionColumns: number;
toolConfigs: number;
mcpProviders: number;
};
onPreview: () => void;
container?: HTMLDivElement | null;
};
export function PreviewDialog({
open,
onOpenChange,
rows,
onRowsChange,
loading,
errors,
summary,
onPreview,
container,
}: PreviewDialogProps): ReactElement {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
container={container}
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="corner-squircle sm:max-w-md"
>
<DialogHeader>
<DialogTitle>Preview data</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Columns</p>
<p className="text-sm font-semibold">{summary.totalColumns}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">LLM</p>
<p className="text-sm font-semibold">{summary.llmColumns}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Samplers</p>
<p className="text-sm font-semibold">{summary.samplerColumns}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Expressions</p>
<p className="text-sm font-semibold">{summary.expressionColumns}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Tool configs</p>
<p className="text-sm font-semibold">{summary.toolConfigs}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">MCP servers</p>
<p className="text-sm font-semibold">{summary.mcpProviders}</p>
</div>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor="preview-rows"
>
Number of records
</label>
<Input
id="preview-rows"
type="number"
min={1}
max={1000}
value={String(rows)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed) && parsed > 0) {
onRowsChange(Math.min(1000, Math.floor(parsed)));
}
}}
/>
</div>
{errors.length > 0 && (
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-destructive/30 bg-destructive/5 p-3">
<p className="text-xs font-semibold uppercase text-destructive">
Validation errors
</p>
{errors.map((error) => (
<p key={error} className="text-xs text-destructive">
{error}
</p>
))}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="button" onClick={onPreview} disabled={loading}>
{loading ? "Running..." : "Run preview"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,290 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { toastError, toastSuccess } from "@/shared/toast";
import { previewRecipe, validateRecipe } from "../api";
import { importRecipePayload, type RecipeSnapshot } from "../utils/import";
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
type SaveTone = "success" | "error";
type PersistRecipeFn = (input: {
id: string | null;
name: string;
payload: RecipePayload;
}) => Promise<{
id: string;
updatedAt: number;
}>;
type UseRecipeStudioActionsParams = {
recipeId: string;
initialRecipeName: string;
initialPayload: RecipePayload;
initialSavedAt: number;
payloadResult: RecipePayloadResult;
onPersistRecipe: PersistRecipeFn;
resetRecipe: () => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
getCurrentPayloadFromStore: () => RecipePayload;
};
type UseRecipeStudioActionsResult = {
workflowName: string;
setWorkflowName: (value: string) => void;
saveLoading: boolean;
saveTone: SaveTone;
savedAtLabel: string;
copied: boolean;
importOpen: boolean;
setImportOpen: (open: boolean) => void;
previewDialogOpen: boolean;
setPreviewDialogOpen: (open: boolean) => void;
previewRows: number;
setPreviewRows: (rows: number) => void;
previewErrors: string[];
previewLoading: boolean;
persistRecipe: () => Promise<void>;
openPreviewDialog: () => void;
runPreview: () => Promise<void>;
copyRecipe: () => Promise<void>;
importRecipe: (value: string) => string | null;
};
function buildSignature(name: string, payload: RecipePayload): string {
return JSON.stringify({ name, payload });
}
function normalizeWorkflowName(value: string): string {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : "Unnamed";
}
function formatSavedLabel(savedAt: number | null): string {
if (!savedAt) {
return "Not saved yet";
}
const time = new Date(savedAt).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
return `Saved ${time}`;
}
function toErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) {
return error.message;
}
return fallback;
}
export function useRecipeStudioActions({
recipeId,
initialRecipeName,
initialPayload,
initialSavedAt,
payloadResult,
onPersistRecipe,
resetRecipe,
loadRecipe,
getCurrentPayloadFromStore,
}: UseRecipeStudioActionsParams): UseRecipeStudioActionsResult {
const [workflowName, setWorkflowName] = useState("Unnamed");
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [savedSignature, setSavedSignature] = useState<string>("");
const [saveLoading, setSaveLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [previewRows, setPreviewRows] = useState(5);
const [previewErrors, setPreviewErrors] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
const normalizedWorkflowName = useMemo(
() => normalizeWorkflowName(workflowName),
[workflowName],
);
const currentPayload = payloadResult.payload;
const currentSignature = useMemo(
() => buildSignature(normalizedWorkflowName, currentPayload),
[currentPayload, normalizedWorkflowName],
);
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: SaveTone = !isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
const payloadErrorMessage = payloadResult.errors[0] ?? "Invalid payload.";
useEffect(() => {
const nextName = normalizeWorkflowName(initialRecipeName);
resetRecipe();
setWorkflowName(nextName);
setLastSavedAt(initialSavedAt);
setCopied(false);
setPreviewErrors([]);
setPreviewDialogOpen(false);
const parsed = importRecipePayload(JSON.stringify(initialPayload));
if (parsed.snapshot) {
loadRecipe(parsed.snapshot);
} else {
console.error("Failed to load recipe payload.", parsed.errors);
}
const payload = getCurrentPayloadFromStore();
setSavedSignature(buildSignature(nextName, payload));
}, [
getCurrentPayloadFromStore,
initialPayload,
initialRecipeName,
initialSavedAt,
loadRecipe,
recipeId,
resetRecipe,
]);
const persistRecipe = useCallback(async (): Promise<void> => {
if (saveLoading) {
return;
}
const nextName = normalizeWorkflowName(workflowName);
if (nextName !== workflowName) {
setWorkflowName(nextName);
}
setSaveLoading(true);
try {
const result = await onPersistRecipe({
id: recipeId,
name: nextName,
payload: currentPayload,
});
setLastSavedAt(result.updatedAt);
setSavedSignature(buildSignature(nextName, currentPayload));
} catch (error) {
console.error("Save recipe failed:", error);
toastError("Save failed", "Could not save recipe.");
} finally {
setSaveLoading(false);
}
}, [currentPayload, onPersistRecipe, recipeId, saveLoading, workflowName]);
useEffect(() => {
if (!isDirty || saveLoading) {
return;
}
const timeoutId = window.setTimeout(() => {
void persistRecipe();
}, 800);
return () => window.clearTimeout(timeoutId);
}, [isDirty, persistRecipe, saveLoading]);
const readPayload = useCallback((): RecipePayload | null => {
if (payloadResult.errors.length === 0) {
return payloadResult.payload;
}
return null;
}, [payloadResult.errors.length, payloadResult.payload]);
function openPreviewDialog(): void {
setPreviewErrors([]);
setPreviewDialogOpen(true);
}
const runPreview = useCallback(async (): Promise<void> => {
setPreviewLoading(true);
const payload = readPayload();
if (!payload) {
setPreviewErrors(payloadResult.errors);
toastError("Invalid recipe payload", payloadErrorMessage);
setPreviewLoading(false);
return;
}
const previewPayload = {
...payload,
run: {
...payload.run,
rows: previewRows,
},
};
try {
const validation = await validateRecipe(previewPayload);
if (!validation.valid) {
const errors = validation.errors.map((item) => item.message);
const fallback = validation.raw_detail ?? "Validation failed.";
const nextErrors = errors.length > 0 ? errors : [fallback];
setPreviewErrors(nextErrors);
toastError("Validation failed", nextErrors[0]);
return;
}
await previewRecipe(previewPayload);
setPreviewDialogOpen(false);
setPreviewErrors([]);
toastSuccess(`Preview generated (${previewRows} rows).`);
} catch (error) {
console.error("Preview failed:", error);
const message = toErrorMessage(error, "Preview request failed.");
setPreviewErrors([message]);
toastError("Preview failed", message);
} finally {
setPreviewLoading(false);
}
}, [payloadErrorMessage, payloadResult.errors, previewRows, readPayload]);
const copyRecipe = useCallback(async (): Promise<void> => {
setCopied(false);
const payload = readPayload();
if (!payload) {
toastError("Copy failed", payloadErrorMessage);
return;
}
if (!navigator.clipboard) {
console.error("Clipboard not available.");
toastError("Copy failed", "Clipboard not available.");
return;
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
toastSuccess("Payload copied");
} catch (error) {
console.error("Copy failed:", error);
toastError("Copy failed", "Could not copy payload.");
}
}, [payloadErrorMessage, readPayload]);
const importRecipe = useCallback(
(value: string): string | null => {
const result = importRecipePayload(value);
if (result.errors.length > 0 || !result.snapshot) {
return result.errors[0] ?? "Invalid payload.";
}
loadRecipe(result.snapshot);
toastSuccess("Recipe imported");
return null;
},
[loadRecipe],
);
return {
workflowName,
setWorkflowName,
saveLoading,
saveTone,
savedAtLabel,
copied,
importOpen,
setImportOpen,
previewDialogOpen,
setPreviewDialogOpen,
previewRows,
setPreviewRows,
previewErrors,
previewLoading,
persistRecipe,
openPreviewDialog,
runPreview,
copyRecipe,
importRecipe,
};
}

View file

@ -19,7 +19,6 @@ import {
} from "react";
import { useShallow } from "zustand/react/shallow";
import "@xyflow/react/dist/style.css";
import { previewRecipe } from "./api";
import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/recipe-graph-aux-node";
import { BlockSheet } from "./components/block-sheet";
import { LayoutControls } from "./components/controls/layout-controls";
@ -31,25 +30,26 @@ import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge
import { DataEdge } from "./components/rf-ui/data-edge";
import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { PreviewDialog } from "./dialogs/preview-dialog";
import { ProcessorsDialog } from "./dialogs/processors-dialog";
import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions";
import { useRecipeStudioStore } from "./stores/recipe-studio";
import type {
RecipeNode as RecipeBuilderNode,
RecipeNodeData,
SamplerConfig,
} from "./types";
import { isCategoryConfig } from "./utils";
import { deriveDisplayGraph } from "./utils/graph/derive-display-graph";
import { importRecipePayload } from "./utils/import";
import { buildRecipePayload } from "./utils/payload";
import type { RecipePayload } from "./utils/payload/types";
import { buildDefaultSchemaTransform } from "./utils/processors";
import {
buildDialogOptions,
buildPreviewSummary,
} from "./utils/recipe-studio-view";
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
type StatusTone = "success" | "error";
export type PersistRecipeInput = {
id: string | null;
name: string;
@ -69,26 +69,6 @@ export type RecipeStudioPageProps = {
onPersistRecipe: (input: PersistRecipeInput) => Promise<PersistRecipeResult>;
};
function buildSignature(name: string, payload: RecipePayload): string {
return JSON.stringify({ name, payload });
}
function normalizeWorkflowName(value: string): string {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : "Unnamed";
}
function formatSavedLabel(savedAt: number | null): string {
if (!savedAt) {
return "Not saved yet";
}
const time = new Date(savedAt).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
return `Saved ${time}`;
}
export function RecipeStudioPage({
recipeId,
initialRecipeName,
@ -168,15 +148,8 @@ export function RecipeStudioPage({
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
null,
);
const [previewLoading, setPreviewLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
const [workflowName, setWorkflowName] = useState("Unnamed");
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [savedSignature, setSavedSignature] = useState<string>("");
const baseNodeIds = useMemo(
() => new Set(nodes.map((node) => node.id)),
@ -266,25 +239,12 @@ export function RecipeStudioPage({
const configList = useMemo(() => Object.values(configs), [configs]);
const config = activeConfigId ? configs[activeConfigId] : null;
const categoryOptions = useMemo<SamplerConfig[]>(
() => configList.filter(isCategoryConfig),
const dialogOptions = useMemo(
() => buildDialogOptions(configList),
[configList],
);
const modelConfigAliases = useMemo<string[]>(
() => configList.filter((item) => item.kind === "model_config").map((item) => item.name),
[configList],
);
const modelProviderOptions = useMemo<string[]>(
() => configList.filter((item) => item.kind === "model_provider").map((item) => item.name),
[configList],
);
const datetimeOptions = useMemo<string[]>(
() =>
configList
.filter(
(item) => item.kind === "sampler" && item.sampler_type === "datetime",
)
.map((item) => item.name),
const previewSummary = useMemo(
() => buildPreviewSummary(configList),
[configList],
);
@ -300,144 +260,46 @@ export function RecipeStudioPage({
() => buildRecipePayload(configs, nodes, edges, processors),
[configs, edges, nodes, processors],
);
const currentPayload = payloadResult.payload;
const normalizedWorkflowName = useMemo(
() => normalizeWorkflowName(workflowName),
[workflowName],
);
const currentSignature = useMemo(
() => buildSignature(normalizedWorkflowName, currentPayload),
[currentPayload, normalizedWorkflowName],
);
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: StatusTone =
!isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
useEffect(() => {
const nextName = normalizeWorkflowName(initialRecipeName);
resetRecipe();
setWorkflowName(nextName);
setLastSavedAt(initialSavedAt);
setCopied(false);
const parsed = importRecipePayload(JSON.stringify(initialPayload));
if (parsed.snapshot) {
loadRecipe(parsed.snapshot);
} else {
console.error("Failed to load recipe payload.", parsed.errors);
}
const getCurrentPayloadFromStore = useCallback((): RecipePayload => {
const state = useRecipeStudioStore.getState();
const { payload } = buildRecipePayload(
return buildRecipePayload(
state.configs,
state.nodes,
state.edges,
state.processors,
);
setSavedSignature(buildSignature(nextName, payload));
}, [
initialPayload,
initialRecipeName,
initialSavedAt,
loadRecipe,
recipeId,
resetRecipe,
]);
const persistRecipe = useCallback(async (): Promise<void> => {
if (saveLoading) {
return;
}
const nextName = normalizeWorkflowName(workflowName);
if (nextName !== workflowName) {
setWorkflowName(nextName);
}
setSaveLoading(true);
try {
const result = await onPersistRecipe({
id: recipeId,
name: nextName,
payload: currentPayload,
});
setLastSavedAt(result.updatedAt);
setSavedSignature(buildSignature(nextName, currentPayload));
} catch (error) {
console.error("Save recipe failed:", error);
} finally {
setSaveLoading(false);
}
}, [
currentPayload,
onPersistRecipe,
recipeId,
saveLoading,
).payload;
}, []);
const {
workflowName,
]);
useEffect(() => {
if (!isDirty || saveLoading) {
return;
}
const timeoutId = window.setTimeout(() => {
void persistRecipe();
}, 800);
return () => window.clearTimeout(timeoutId);
}, [isDirty, persistRecipe, saveLoading]);
const readPayload = useCallback(
() => {
if (payloadResult.errors.length === 0) {
return payloadResult.payload;
}
return null;
},
[payloadResult.errors.length, payloadResult.payload],
);
const handlePreview = async (): Promise<void> => {
setPreviewLoading(true);
const payload = readPayload();
if (!payload) {
setPreviewLoading(false);
return;
}
try {
await previewRecipe(payload);
} catch (error) {
console.error("Preview failed:", error);
} finally {
setPreviewLoading(false);
}
};
const handleCopyRecipe = async (): Promise<void> => {
setCopied(false);
const payload = readPayload();
if (!payload) {
return;
}
if (!navigator.clipboard) {
console.error("Clipboard not available.");
return;
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.error("Copy failed:", error);
}
};
const handleImport = (value: string): string | null => {
const result = importRecipePayload(value);
if (result.errors.length > 0 || !result.snapshot) {
return result.errors[0] ?? "Invalid payload.";
}
loadRecipe(result.snapshot);
return null;
};
setWorkflowName,
saveLoading,
saveTone,
savedAtLabel,
copied,
importOpen,
setImportOpen,
previewDialogOpen,
setPreviewDialogOpen,
previewRows,
setPreviewRows,
previewErrors,
previewLoading,
persistRecipe,
openPreviewDialog,
runPreview,
copyRecipe,
importRecipe,
} = useRecipeStudioActions({
recipeId,
initialRecipeName,
initialPayload,
initialSavedAt,
payloadResult,
onPersistRecipe,
resetRecipe,
loadRecipe,
getCurrentPayloadFromStore,
});
const openProcessorsFromSheet = useCallback(() => {
if (
@ -464,7 +326,7 @@ export function RecipeStudioPage({
savedAtLabel={savedAtLabel}
workflowName={workflowName}
onWorkflowNameChange={setWorkflowName}
onPreview={handlePreview}
onPreview={openPreviewDialog}
onSaveRecipe={() => {
void persistRecipe();
}}
@ -515,7 +377,7 @@ export function RecipeStudioPage({
onAddExpression={addExpressionNode}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={handleCopyRecipe}
onCopy={copyRecipe}
onImport={() => setImportOpen(true)}
/>
</Panel>
@ -531,17 +393,17 @@ export function RecipeStudioPage({
open={dialogOpen}
onOpenChange={setDialogOpen}
config={config}
categoryOptions={categoryOptions}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
datetimeOptions={datetimeOptions}
categoryOptions={dialogOptions.categoryOptions}
modelConfigAliases={dialogOptions.modelConfigAliases}
modelProviderOptions={dialogOptions.modelProviderOptions}
datetimeOptions={dialogOptions.datetimeOptions}
onUpdate={updateConfig}
container={sheetContainer}
/>
<ImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImport={handleImport}
onImport={importRecipe}
container={sheetContainer}
/>
<ProcessorsDialog
@ -551,6 +413,19 @@ export function RecipeStudioPage({
onProcessorsChange={setProcessors}
container={sheetContainer}
/>
<PreviewDialog
open={previewDialogOpen}
onOpenChange={setPreviewDialogOpen}
rows={previewRows}
onRowsChange={setPreviewRows}
loading={previewLoading}
errors={previewErrors}
summary={previewSummary}
onPreview={() => {
void runPreview();
}}
container={sheetContainer}
/>
</div>
);
}

View file

@ -102,6 +102,41 @@ export type Score = {
options: ScoreOption[];
};
export type McpProviderType = "stdio" | "streamable_http";
export type McpEnvVar = {
key: string;
value: string;
};
export type LlmMcpProviderConfig = {
id: string;
name: string;
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: McpProviderType;
command?: string;
args?: string[];
env?: McpEnvVar[];
endpoint?: string;
// biome-ignore lint/style/useNamingConvention: api schema
api_key?: string;
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env?: string;
};
export type LlmToolConfig = {
id: string;
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: string;
providers: string[];
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools?: string[];
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns?: string;
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec?: string;
};
export type LlmConfig = {
id: string;
kind: "llm";
@ -118,6 +153,12 @@ export type LlmConfig = {
code_lang?: string;
// biome-ignore lint/style/useNamingConvention: api schema
output_format?: string;
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias?: string;
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs?: LlmToolConfig[];
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers?: LlmMcpProviderConfig[];
scores?: Score[];
};

View file

@ -202,6 +202,12 @@ export function makeLlmConfig(
// biome-ignore lint/style/useNamingConvention: api schema
output_format:
llmType === "structured" ? '{\n "field": "string"\n}' : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: "",
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: [],
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers: [],
scores:
llmType === "judge"
? [

View file

@ -1,4 +1,10 @@
import type { RecipeProcessorConfig, NodeConfig } from "../../types";
import type {
LlmConfig,
LlmMcpProviderConfig,
LlmToolConfig,
NodeConfig,
RecipeProcessorConfig,
} from "../../types";
import { buildEdges } from "./edges";
import { isRecord, parseJson, readString } from "./helpers";
import {
@ -13,6 +19,8 @@ type RecipeInput = {
columns?: unknown;
model_configs?: unknown;
model_providers?: unknown;
mcp_providers?: unknown;
tool_configs?: unknown;
processors?: unknown;
};
@ -55,6 +63,134 @@ function parseProcessors(input: unknown): RecipeProcessorConfig[] {
return processors;
}
function parseMcpProviders(
input: unknown,
): Map<string, LlmMcpProviderConfig> {
const providers = new Map<string, LlmMcpProviderConfig>();
if (!Array.isArray(input)) {
return providers;
}
input.forEach((item, index) => {
if (!isRecord(item)) {
return;
}
const name = readString(item.name)?.trim();
if (!name) {
return;
}
const providerTypeRaw = readString(item.provider_type);
const providerType =
providerTypeRaw === "stdio" ? "stdio" : "streamable_http";
const args = Array.isArray(item.args)
? item.args.map((value) => String(value))
: [];
const envPairs =
isRecord(item.env)
? Object.entries(item.env).map(([key, value]) => ({
key: String(key),
value: String(value),
}))
: [];
providers.set(name, {
id: `mcp-${index + 1}`,
name,
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: providerType,
command: readString(item.command) ?? "",
args,
env: envPairs,
endpoint: readString(item.endpoint) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: readString(item.api_key) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: readString(item.api_key_env) ?? "",
});
});
return providers;
}
function parseToolConfigs(input: unknown): Map<string, LlmToolConfig> {
const toolConfigs = new Map<string, LlmToolConfig>();
if (!Array.isArray(input)) {
return toolConfigs;
}
input.forEach((item, index) => {
if (!isRecord(item)) {
return;
}
const toolAlias = readString(item.tool_alias)?.trim();
if (!toolAlias) {
return;
}
const providers = Array.isArray(item.providers)
? item.providers.map((value) => String(value).trim()).filter(Boolean)
: [];
const allowTools = Array.isArray(item.allow_tools)
? item.allow_tools.map((value) => String(value).trim()).filter(Boolean)
: [];
toolConfigs.set(toolAlias, {
id: `tool-${index + 1}`,
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: toolAlias,
providers,
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: allowTools,
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns:
item.max_tool_call_turns === null || item.max_tool_call_turns === undefined
? "5"
: String(item.max_tool_call_turns),
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec:
item.timeout_sec === null || item.timeout_sec === undefined
? ""
: String(item.timeout_sec),
});
});
return toolConfigs;
}
function cloneToolConfig(config: LlmToolConfig): LlmToolConfig {
return {
...config,
providers: [...config.providers],
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [...(config.allow_tools ?? [])],
};
}
function cloneMcpProvider(config: LlmMcpProviderConfig): LlmMcpProviderConfig {
return {
...config,
args: [...(config.args ?? [])],
env: [...(config.env ?? [])],
};
}
function attachLlmTooling(
config: LlmConfig,
toolConfigsByAlias: Map<string, LlmToolConfig>,
mcpProvidersByName: Map<string, LlmMcpProviderConfig>,
): void {
const toolAlias = config.tool_alias?.trim();
if (!toolAlias) {
config.tool_alias = "";
config.tool_configs = [];
config.mcp_providers = [];
return;
}
const toolConfig = toolConfigsByAlias.get(toolAlias);
if (!toolConfig) {
config.tool_configs = [];
config.mcp_providers = [];
return;
}
config.tool_configs = [cloneToolConfig(toolConfig)];
config.mcp_providers = toolConfig.providers
.map((providerName) => mcpProvidersByName.get(providerName))
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : []));
}
export function importRecipePayload(input: string): ImportResult {
const parsed = parseJson(input);
if (!parsed.data || !isRecord(parsed.data)) {
@ -76,6 +212,8 @@ export function importRecipePayload(input: string): ImportResult {
const errors: string[] = [];
const configs: NodeConfig[] = [];
const processors = parseProcessors(recipe.processors);
const mcpProvidersByName = parseMcpProviders(recipe.mcp_providers);
const toolConfigsByAlias = parseToolConfigs(recipe.tool_configs);
const nameToId = new Map<string, string>();
let nextId = 1;
@ -137,6 +275,9 @@ export function importRecipePayload(input: string): ImportResult {
if (!config) {
return;
}
if (config.kind === "llm") {
attachLlmTooling(config, toolConfigsByAlias, mcpProvidersByName);
}
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
return;

View file

@ -60,6 +60,8 @@ export function parseLlm(
code_lang: readString(column.code_lang) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
output_format: normalizeOutputFormat(column.output_format),
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: readString(column.tool_alias) ?? "",
scores: llmType === "judge" ? scores : undefined,
};
}

View file

@ -9,6 +9,8 @@ import type {
import { getConfigErrors } from "../index";
import {
buildExpressionColumn,
buildLlmMcpProvider,
buildLlmToolConfig,
buildLlmColumn,
buildModelConfig,
buildModelProvider,
@ -53,9 +55,14 @@ export function buildRecipePayload(
const modelAliases = new Set<string>();
const modelProviderNames = new Set<string>();
const modelProviders: Record<string, unknown>[] = [];
const mcpProviders: Record<string, unknown>[] = [];
const modelConfigs: Record<string, unknown>[] = [];
const toolConfigs: Record<string, unknown>[] = [];
const modelProviderConfigs: ModelProviderConfig[] = [];
const modelConfigConfigs: ModelConfig[] = [];
const llmToolAliasesUsed = new Set<string>();
const mcpProviderJsonByName = new Map<string, string>();
const toolConfigJsonByAlias = new Map<string, string>();
const nameSet = new Set<string>();
const nameToConfig = new Map<string, NodeConfig>();
@ -79,9 +86,47 @@ export function buildRecipePayload(
}
if (config.kind === "llm") {
columns.push(buildLlmColumn(config, errors));
for (const provider of config.mcp_providers ?? []) {
const builtProvider = buildLlmMcpProvider(provider, errors);
if (!builtProvider) {
continue;
}
const key = String(builtProvider.name);
const serialized = JSON.stringify(builtProvider);
const existing = mcpProviderJsonByName.get(key);
if (existing && existing !== serialized) {
errors.push(`MCP provider ${key}: conflicting definitions.`);
continue;
}
if (!existing) {
mcpProviderJsonByName.set(key, serialized);
mcpProviders.push(builtProvider);
}
}
for (const toolConfig of config.tool_configs ?? []) {
const builtToolConfig = buildLlmToolConfig(toolConfig, errors);
if (!builtToolConfig) {
continue;
}
const key = String(builtToolConfig.tool_alias);
const serialized = JSON.stringify(builtToolConfig);
const existing = toolConfigJsonByAlias.get(key);
if (existing && existing !== serialized) {
errors.push(`Tool config ${key}: conflicting definitions.`);
continue;
}
if (!existing) {
toolConfigJsonByAlias.set(key, serialized);
toolConfigs.push(builtToolConfig);
}
}
if (config.model_alias) {
modelAliases.add(config.model_alias);
}
const toolAlias = config.tool_alias?.trim();
if (toolAlias) {
llmToolAliasesUsed.add(toolAlias);
}
nameToConfig.set(config.name, config);
continue;
}
@ -110,6 +155,11 @@ export function buildRecipePayload(
errors,
);
validateUsedProviders(modelProviderConfigs, modelConfigConfigs, errors);
for (const toolAlias of llmToolAliasesUsed) {
if (!toolConfigJsonByAlias.has(toolAlias)) {
errors.push(`Tool alias ${toolAlias}: missing tool config.`);
}
}
const uiNodes = nodes.flatMap((node) => {
const config = configs[node.id];
@ -153,7 +203,11 @@ export function buildRecipePayload(
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: modelProviders,
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: mcpProviders,
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: modelConfigs,
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: toolConfigs,
columns,
processors: recipeProcessors,
},

View file

@ -0,0 +1,176 @@
import type { LlmConfig, LlmMcpProviderConfig, LlmToolConfig } from "../../types";
export function buildLlmColumn(
config: LlmConfig,
errors: string[],
): Record<string, unknown> {
const toolAlias = config.tool_alias?.trim();
const base = {
name: config.name,
drop: config.drop ?? false,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: config.model_alias,
prompt: config.prompt,
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: config.system_prompt || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: toolAlias || undefined,
};
if (config.llm_type === "code") {
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-code",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: config.code_lang || "python",
};
}
if (config.llm_type === "structured") {
let outputFormat: unknown = config.output_format || undefined;
if (typeof outputFormat === "string" && outputFormat.trim()) {
try {
outputFormat = JSON.parse(outputFormat);
} catch {
errors.push(`LLM ${config.name}: output_format is not valid JSON.`);
}
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-structured",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
output_format: outputFormat,
};
}
if (config.llm_type === "judge") {
const scores = (config.scores ?? [])
.map((score) => {
const options: Record<string, string> = {};
for (const option of score.options ?? []) {
const key = option.value.trim();
const value = option.description.trim();
if (!key || !value) {
continue;
}
options[key] = value;
}
return {
name: score.name.trim(),
description: score.description.trim(),
options,
};
})
.filter(
(score) =>
score.name && score.description && Object.keys(score.options).length > 0,
);
if (scores.length === 0) {
errors.push(`LLM ${config.name}: scores required for LLM Judge.`);
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-judge",
...base,
scores,
};
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-text",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: "none",
};
}
export function buildLlmMcpProvider(
provider: LlmMcpProviderConfig,
errors: string[],
): Record<string, unknown> | null {
const name = provider.name.trim();
if (!name) {
errors.push("MCP provider: name is required.");
return null;
}
if (provider.provider_type === "stdio") {
const command = provider.command?.trim() ?? "";
if (!command) {
errors.push(`MCP provider ${name}: command is required for stdio.`);
return null;
}
const env: Record<string, string> = {};
for (const item of provider.env ?? []) {
const key = item.key.trim();
const value = item.value.trim();
if (key && value) {
env[key] = value;
}
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "stdio",
name,
command,
args: (provider.args ?? []).map((value) => value.trim()).filter(Boolean),
env,
};
}
const endpoint = provider.endpoint?.trim() ?? "";
if (!endpoint) {
errors.push(`MCP provider ${name}: endpoint is required.`);
return null;
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "streamable_http",
name,
endpoint,
// biome-ignore lint/style/useNamingConvention: api schema
api_key: provider.api_key?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: provider.api_key_env?.trim() || undefined,
};
}
export function buildLlmToolConfig(
config: LlmToolConfig,
errors: string[],
): Record<string, unknown> | null {
const toolAlias = config.tool_alias.trim();
if (!toolAlias) {
errors.push("Tool config: tool_alias is required.");
return null;
}
const providers = config.providers
.map((value) => value.trim())
.filter(Boolean);
if (providers.length === 0) {
errors.push(`Tool config ${toolAlias}: at least one provider is required.`);
return null;
}
const allowTools = (config.allow_tools ?? [])
.map((value) => value.trim())
.filter(Boolean);
const maxToolCallTurnsRaw = config.max_tool_call_turns?.trim();
const maxToolCallTurns =
maxToolCallTurnsRaw && Number.isFinite(Number(maxToolCallTurnsRaw))
? Number(maxToolCallTurnsRaw)
: 5;
const timeoutRaw = config.timeout_sec?.trim();
const timeoutSec =
timeoutRaw && Number.isFinite(Number(timeoutRaw))
? Number(timeoutRaw)
: undefined;
return {
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: toolAlias,
providers,
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: allowTools.length > 0 ? allowTools : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: maxToolCallTurns,
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: timeoutSec,
};
}

View file

@ -0,0 +1,71 @@
import type { ModelConfig, ModelProviderConfig } from "../../types";
import { parseJsonObject } from "./parse";
export function buildModelProvider(
config: ModelProviderConfig,
errors: string[],
): Record<string, unknown> {
const extraHeaders = parseJsonObject(
config.extra_headers,
`Provider ${config.name} extra_headers`,
errors,
);
const extraBody = parseJsonObject(
config.extra_body,
`Provider ${config.name} extra_body`,
errors,
);
return {
name: config.name,
endpoint: config.endpoint,
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: config.provider_type,
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: config.api_key_env?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
api_key: config.api_key?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: extraHeaders ?? {},
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: extraBody ?? {},
};
}
export function buildModelConfig(config: ModelConfig): Record<string, unknown> {
const inference: Record<string, unknown> = {};
const temp = config.inference_temperature?.trim();
const topP = config.inference_top_p?.trim();
const maxTokens = config.inference_max_tokens?.trim();
if (temp) {
const parsed = Number(temp);
if (Number.isFinite(parsed)) {
inference.temperature = parsed;
}
}
if (topP) {
const parsed = Number(topP);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.top_p = parsed;
}
}
if (maxTokens) {
const parsed = Number(maxTokens);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.max_tokens = parsed;
}
}
return {
alias: config.name,
model: config.model,
provider: config.provider || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
inference_parameters:
Object.keys(inference).length > 0 ? inference : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check: config.skip_health_check || undefined,
};
}

View file

@ -0,0 +1,53 @@
import type { ExpressionConfig, RecipeProcessorConfig } from "../../types";
import { parseJsonObject } from "./parse";
export 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,
drop: config.drop ?? false,
expr: config.expr,
dtype: config.dtype,
};
}
export function buildProcessors(
processors: RecipeProcessorConfig[],
errors: string[],
): Record<string, unknown>[] {
const output: Record<string, unknown>[] = [];
for (const processor of processors) {
if (processor.processor_type !== "schema_transform") {
continue;
}
const name = processor.name.trim();
if (!name) {
errors.push("Schema transform: name is required.");
continue;
}
const template = parseJsonObject(
processor.template,
`Schema transform ${name} template`,
errors,
);
if (!template) {
continue;
}
output.push({
// biome-ignore lint/style/useNamingConvention: api schema
processor_type: "schema_transform",
name,
// biome-ignore lint/style/useNamingConvention: api schema
build_stage: "post_batch",
template,
});
}
return output;
}

View file

@ -0,0 +1,171 @@
import type { CategoryConditionalParams, SamplerConfig } from "../../types";
import { isValidSex, parseAgeRange, parseNumber } from "./parse";
function buildCategoryConditionalParams(
config: SamplerConfig,
errors: string[],
): Record<string, CategoryConditionalParams> | undefined {
const conditional = config.conditional_params ?? {};
const output: Record<string, CategoryConditionalParams> = {};
for (const [rawCondition, params] of Object.entries(conditional)) {
const condition = rawCondition.trim();
if (!condition) {
errors.push(`Sampler ${config.name}: conditional rule needs condition text.`);
continue;
}
const values = (params.values ?? [])
.map((value) => value.trim())
.filter(Boolean);
if (values.length === 0) {
errors.push(`Sampler ${config.name}: conditional '${condition}' needs values.`);
continue;
}
const weights = params.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (
hasWeights &&
(weights.length !== values.length || weights.some((weight) => weight === null))
) {
errors.push(`Sampler ${config.name}: conditional '${condition}' weights invalid.`);
continue;
}
output[condition] = {
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
values,
weights: hasWeights
? weights.filter((weight): weight is number => weight !== null)
: undefined,
};
}
return Object.keys(output).length > 0 ? output : undefined;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per type logic
function buildSamplerParams(
config: SamplerConfig,
errors: string[],
): Record<string, unknown> {
if (config.sampler_type === "category") {
const values = config.values ?? [];
const params: Record<string, unknown> = { values };
const weights = config.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (hasWeights && weights.some((weight) => weight === null)) {
errors.push(`Sampler ${config.name}: weights missing values.`);
} else if (hasWeights) {
params.weights = weights.filter((weight) => weight !== null);
}
return params;
}
if (config.sampler_type === "subcategory") {
const mapping = config.subcategory_mapping ?? {};
for (const [key, values] of Object.entries(mapping)) {
if (!values || values.length === 0) {
errors.push(
`Subcategory ${config.name}: '${key}' needs at least 1 subcategory.`,
);
}
}
return {
category: config.subcategory_parent,
values: mapping,
};
}
if (config.sampler_type === "uniform") {
return {
low: parseNumber(config.low),
high: parseNumber(config.high),
};
}
if (config.sampler_type === "gaussian") {
return {
mean: parseNumber(config.mean),
std: parseNumber(config.std),
};
}
if (config.sampler_type === "bernoulli") {
return {
p: parseNumber(config.p),
};
}
if (config.sampler_type === "datetime") {
return {
start: config.datetime_start ?? undefined,
end: config.datetime_end ?? undefined,
unit: config.datetime_unit ?? undefined,
};
}
if (config.sampler_type === "timedelta") {
return {
// biome-ignore lint/style/useNamingConvention: api schema
dt_min: parseNumber(config.dt_min),
// biome-ignore lint/style/useNamingConvention: api schema
dt_max: parseNumber(config.dt_max),
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: config.reference_column_name || undefined,
unit: config.timedelta_unit || undefined,
};
}
if (config.sampler_type === "uuid") {
return {
format: config.uuid_format ?? undefined,
};
}
const params: Record<string, unknown> = {};
if (config.person_locale?.trim()) {
params.locale = config.person_locale.trim();
}
if (config.sampler_type === "person") {
if (isValidSex(config.person_sex?.trim())) {
params.sex = config.person_sex?.trim();
} else if (config.person_sex?.trim()) {
errors.push(`Person ${config.name}: sex must be Male or Female.`);
}
} else if (config.person_sex?.trim()) {
params.sex = config.person_sex.trim();
}
if (config.person_city?.trim()) {
params.city = config.person_city.trim();
}
if (config.person_age_range?.trim()) {
const parsed = parseAgeRange(config.person_age_range);
if (parsed) {
// biome-ignore lint/style/useNamingConvention: api schema
params.age_range = parsed;
} else {
errors.push(`Person ${config.name}: age range must be like 18-70.`);
}
}
if (config.sampler_type === "person") {
// biome-ignore lint/style/useNamingConvention: api schema
params.with_synthetic_personas =
config.person_with_synthetic_personas ?? undefined;
}
return params;
}
export function buildSamplerColumn(
config: SamplerConfig,
errors: string[],
): Record<string, unknown> {
const samplerColumn: Record<string, unknown> = {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "sampler",
name: config.name,
drop: config.drop ?? false,
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: config.sampler_type,
params: buildSamplerParams(config, errors),
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: config.convert_to ?? undefined,
};
if (config.sampler_type === "category") {
const conditionalParams = buildCategoryConditionalParams(config, errors);
if (conditionalParams) {
// biome-ignore lint/style/useNamingConvention: api schema
samplerColumn.conditional_params = conditionalParams;
}
}
return samplerColumn;
}

View file

@ -1,383 +1,4 @@
import type {
RecipeProcessorConfig,
CategoryConditionalParams,
ExpressionConfig,
LlmConfig,
ModelConfig,
ModelProviderConfig,
SamplerConfig,
} from "../../types";
import {
isValidSex,
parseAgeRange,
parseJsonObject,
parseNumber,
} from "./parse";
function buildCategoryConditionalParams(
config: SamplerConfig,
errors: string[],
): Record<string, CategoryConditionalParams> | undefined {
const conditional = config.conditional_params ?? {};
const output: Record<string, CategoryConditionalParams> = {};
for (const [rawCondition, params] of Object.entries(conditional)) {
const condition = rawCondition.trim();
if (!condition) {
errors.push(`Sampler ${config.name}: conditional rule needs condition text.`);
continue;
}
const values = (params.values ?? [])
.map((value) => value.trim())
.filter(Boolean);
if (values.length === 0) {
errors.push(`Sampler ${config.name}: conditional '${condition}' needs values.`);
continue;
}
const weights = params.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (
hasWeights &&
(weights.length !== values.length || weights.some((weight) => weight === null))
) {
errors.push(`Sampler ${config.name}: conditional '${condition}' weights invalid.`);
continue;
}
output[condition] = {
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
values,
weights: hasWeights
? weights.filter((weight): weight is number => weight !== null)
: undefined,
};
}
return Object.keys(output).length > 0 ? output : undefined;
}
export function buildModelProvider(
config: ModelProviderConfig,
errors: string[],
): Record<string, unknown> {
const extraHeaders = parseJsonObject(
config.extra_headers,
`Provider ${config.name} extra_headers`,
errors,
);
const extraBody = parseJsonObject(
config.extra_body,
`Provider ${config.name} extra_body`,
errors,
);
return {
name: config.name,
endpoint: config.endpoint,
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: config.provider_type,
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: config.api_key_env?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
api_key: config.api_key?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: extraHeaders ?? {},
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: extraBody ?? {},
};
}
export function buildModelConfig(config: ModelConfig): Record<string, unknown> {
const inference: Record<string, unknown> = {};
const temp = config.inference_temperature?.trim();
const topP = config.inference_top_p?.trim();
const maxTokens = config.inference_max_tokens?.trim();
if (temp) {
const parsed = Number(temp);
if (Number.isFinite(parsed)) {
inference.temperature = parsed;
}
}
if (topP) {
const parsed = Number(topP);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.top_p = parsed;
}
}
if (maxTokens) {
const parsed = Number(maxTokens);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.max_tokens = parsed;
}
}
return {
alias: config.name,
model: config.model,
provider: config.provider || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
inference_parameters:
Object.keys(inference).length > 0 ? inference : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check: config.skip_health_check || undefined,
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per type logic
function buildSamplerParams(
config: SamplerConfig,
errors: string[],
): Record<string, unknown> {
if (config.sampler_type === "category") {
const values = config.values ?? [];
const params: Record<string, unknown> = { values };
const weights = config.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (hasWeights && weights.some((weight) => weight === null)) {
errors.push(`Sampler ${config.name}: weights missing values.`);
} else if (hasWeights) {
params.weights = weights.filter((weight) => weight !== null);
}
return params;
}
if (config.sampler_type === "subcategory") {
const mapping = config.subcategory_mapping ?? {};
for (const [key, values] of Object.entries(mapping)) {
if (!values || values.length === 0) {
errors.push(
`Subcategory ${config.name}: '${key}' needs at least 1 subcategory.`,
);
}
}
return {
category: config.subcategory_parent,
values: mapping,
};
}
if (config.sampler_type === "uniform") {
return {
low: parseNumber(config.low),
high: parseNumber(config.high),
};
}
if (config.sampler_type === "gaussian") {
return {
mean: parseNumber(config.mean),
std: parseNumber(config.std),
};
}
if (config.sampler_type === "bernoulli") {
return {
p: parseNumber(config.p),
};
}
if (config.sampler_type === "datetime") {
return {
start: config.datetime_start ?? undefined,
end: config.datetime_end ?? undefined,
unit: config.datetime_unit ?? undefined,
};
}
if (config.sampler_type === "timedelta") {
return {
// biome-ignore lint/style/useNamingConvention: api schema
dt_min: parseNumber(config.dt_min),
// biome-ignore lint/style/useNamingConvention: api schema
dt_max: parseNumber(config.dt_max),
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: config.reference_column_name || undefined,
unit: config.timedelta_unit || undefined,
};
}
if (config.sampler_type === "uuid") {
return {
format: config.uuid_format ?? undefined,
};
}
const params: Record<string, unknown> = {};
if (config.person_locale?.trim()) {
params.locale = config.person_locale.trim();
}
if (config.sampler_type === "person") {
if (isValidSex(config.person_sex?.trim())) {
params.sex = config.person_sex?.trim();
} else if (config.person_sex?.trim()) {
errors.push(`Person ${config.name}: sex must be Male or Female.`);
}
} else if (config.person_sex?.trim()) {
params.sex = config.person_sex.trim();
}
if (config.person_city?.trim()) {
params.city = config.person_city.trim();
}
if (config.person_age_range?.trim()) {
const parsed = parseAgeRange(config.person_age_range);
if (parsed) {
// biome-ignore lint/style/useNamingConvention: api schema
params.age_range = parsed;
} else {
errors.push(`Person ${config.name}: age range must be like 18-70.`);
}
}
if (config.sampler_type === "person") {
// biome-ignore lint/style/useNamingConvention: api schema
params.with_synthetic_personas =
config.person_with_synthetic_personas ?? undefined;
}
return params;
}
export function buildSamplerColumn(
config: SamplerConfig,
errors: string[],
): Record<string, unknown> {
const samplerColumn: Record<string, unknown> = {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "sampler",
name: config.name,
drop: config.drop ?? false,
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: config.sampler_type,
params: buildSamplerParams(config, errors),
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: config.convert_to ?? undefined,
};
if (config.sampler_type === "category") {
const conditionalParams = buildCategoryConditionalParams(config, errors);
if (conditionalParams) {
// biome-ignore lint/style/useNamingConvention: api schema
samplerColumn.conditional_params = conditionalParams;
}
}
return samplerColumn;
}
export function buildLlmColumn(
config: LlmConfig,
errors: string[],
): Record<string, unknown> {
const base = {
name: config.name,
drop: config.drop ?? false,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: config.model_alias,
prompt: config.prompt,
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: config.system_prompt || undefined,
};
if (config.llm_type === "code") {
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-code",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: config.code_lang || "python",
};
}
if (config.llm_type === "structured") {
let outputFormat: unknown = config.output_format || undefined;
if (typeof outputFormat === "string" && outputFormat.trim()) {
try {
outputFormat = JSON.parse(outputFormat);
} catch {
errors.push(`LLM ${config.name}: output_format is not valid JSON.`);
}
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-structured",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
output_format: outputFormat,
};
}
if (config.llm_type === "judge") {
const scores = (config.scores ?? [])
.map((score) => {
const options: Record<string, string> = {};
for (const option of score.options ?? []) {
const key = option.value.trim();
const value = option.description.trim();
if (!key || !value) {
continue;
}
options[key] = value;
}
return {
name: score.name.trim(),
description: score.description.trim(),
options,
};
})
.filter(
(score) =>
score.name && score.description && Object.keys(score.options).length > 0,
);
if (scores.length === 0) {
errors.push(`LLM ${config.name}: scores required for LLM Judge.`);
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-judge",
...base,
scores,
};
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-text",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: false,
};
}
export 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,
drop: config.drop ?? false,
expr: config.expr,
dtype: config.dtype,
};
}
export function buildProcessors(
processors: RecipeProcessorConfig[],
errors: string[],
): Record<string, unknown>[] {
const output: Record<string, unknown>[] = [];
for (const processor of processors) {
if (processor.processor_type !== "schema_transform") {
continue;
}
const name = processor.name.trim();
if (!name) {
errors.push("Schema transform: name is required.");
continue;
}
const template = parseJsonObject(
processor.template,
`Schema transform ${name} template`,
errors,
);
if (!template) {
continue;
}
output.push({
// biome-ignore lint/style/useNamingConvention: api schema
processor_type: "schema_transform",
name,
// biome-ignore lint/style/useNamingConvention: api schema
build_stage: "post_batch",
template,
});
}
return output;
}
export { buildLlmColumn, buildLlmMcpProvider, buildLlmToolConfig } from "./builders-llm";
export { buildModelConfig, buildModelProvider } from "./builders-model";
export { buildExpressionColumn, buildProcessors } from "./builders-processors";
export { buildSamplerColumn } from "./builders-sampler";

View file

@ -3,7 +3,11 @@ export type RecipePayload = {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: Record<string, unknown>[];
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: Record<string, unknown>[];
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: Record<string, unknown>[];
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: Record<string, unknown>[];
columns: Record<string, unknown>[];
processors: Record<string, unknown>[];
};

View file

@ -0,0 +1,100 @@
import type { NodeConfig, SamplerConfig } from "../types";
export type PreviewSummary = {
totalColumns: number;
llmColumns: number;
samplerColumns: number;
expressionColumns: number;
toolConfigs: number;
mcpProviders: number;
};
export type DialogOptions = {
categoryOptions: SamplerConfig[];
modelConfigAliases: string[];
modelProviderOptions: string[];
datetimeOptions: string[];
};
export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
const categoryOptions: SamplerConfig[] = [];
const modelConfigAliases: string[] = [];
const modelProviderOptions: string[] = [];
const datetimeOptions: string[] = [];
for (const config of configList) {
if (config.kind === "sampler") {
if (config.sampler_type === "category") {
categoryOptions.push(config);
}
if (config.sampler_type === "datetime") {
datetimeOptions.push(config.name);
}
continue;
}
if (config.kind === "model_config") {
modelConfigAliases.push(config.name);
continue;
}
if (config.kind === "model_provider") {
modelProviderOptions.push(config.name);
}
}
return {
categoryOptions,
modelConfigAliases,
modelProviderOptions,
datetimeOptions,
};
}
export function buildPreviewSummary(configList: NodeConfig[]): PreviewSummary {
const toolConfigAliases = new Set<string>();
const mcpProviderNames = new Set<string>();
let totalColumns = 0;
let llmColumns = 0;
let samplerColumns = 0;
let expressionColumns = 0;
for (const config of configList) {
if (config.kind === "sampler") {
totalColumns += 1;
samplerColumns += 1;
continue;
}
if (config.kind === "expression") {
totalColumns += 1;
expressionColumns += 1;
continue;
}
if (config.kind !== "llm") {
continue;
}
totalColumns += 1;
llmColumns += 1;
for (const toolConfig of config.tool_configs ?? []) {
const toolAlias = toolConfig.tool_alias.trim();
if (toolAlias) {
toolConfigAliases.add(toolAlias);
}
}
for (const provider of config.mcp_providers ?? []) {
const providerName = provider.name.trim();
if (providerName) {
mcpProviderNames.add(providerName);
}
}
}
return {
totalColumns,
llmColumns,
samplerColumns,
expressionColumns,
toolConfigs: toolConfigAliases.size,
mcpProviders: mcpProviderNames.size,
};
}

View file

@ -0,0 +1,11 @@
import { toast } from "sonner";
export function toastSuccess(message: string): void {
toast.success(message);
}
export function toastError(message: string, description?: string): void {
toast.error(message, {
description,
});
}

View file

@ -21,6 +21,14 @@ export default defineConfig({
target: "http://127.0.0.1:8004",
changeOrigin: true,
},
"/validate": {
target: "http://127.0.0.1:8004",
changeOrigin: true,
},
"/tools": {
target: "http://127.0.0.1:8004",
changeOrigin: true,
},
},
},
resolve: {