feat: add builders and components for LLM configuration in Recipe Studio and refactor for readability, preparing for draft
This commit is contained in:
parent
30cc509197
commit
e145a72adb
31 changed files with 2644 additions and 889 deletions
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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] ?? "";
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
11
studio/frontend/src/shared/toast.ts
Normal file
11
studio/frontend/src/shared/toast.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue