diff --git a/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx b/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx index 29c3dfe34a..0ee88ac0a1 100644 --- a/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx +++ b/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx @@ -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 ; + return ; } diff --git a/studio/frontend/src/features/data-recipes/data/recipes-db.ts b/studio/frontend/src/features/data-recipes/data/recipes-db.ts index 77208bf3d3..5b7fd0c189 100644 --- a/studio/frontend/src/features/data-recipes/data/recipes-db.ts +++ b/studio/frontend/src/features/data-recipes/data/recipes-db.ts @@ -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: [], }, diff --git a/studio/frontend/src/features/data-recipes/index.ts b/studio/frontend/src/features/data-recipes/index.ts index 9e830ab42c..9c0e59e147 100644 --- a/studio/frontend/src/features/data-recipes/index.ts +++ b/studio/frontend/src/features/data-recipes/index.ts @@ -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"; diff --git a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx index 26e329ddbb..212e74c212 100644 --- a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx +++ b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx @@ -46,21 +46,20 @@ export function DataRecipesPage(): ReactElement { const recipes = useRecipes(); const [creatingRecipe, setCreatingRecipe] = useState(false); - function openNewRecipe(): void { + async function openNewRecipe(): Promise { 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.

- @@ -102,7 +107,13 @@ export function DataRecipesPage(): ReactElement { - diff --git a/studio/frontend/src/features/data-recipes/pages/edit-recipe-page.tsx b/studio/frontend/src/features/data-recipes/pages/edit-recipe-page.tsx index 6c099e639b..329e0ea87d 100644 --- a/studio/frontend/src/features/data-recipes/pages/edit-recipe-page.tsx +++ b/studio/frontend/src/features/data-recipes/pages/edit-recipe-page.tsx @@ -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({ status: "loading" }); diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index 47bc496a1f..c5b2f73756 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -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; + // biome-ignore lint/style/useNamingConvention: api schema + processor_artifacts?: Record; }; -export async function previewRecipe( - payload: unknown, -): Promise { - 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; + tools: string[]; +}; + +async function parseErrorResponse(response: Response): Promise { + 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(path: string, payload: unknown): Promise { + 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 { + return postJson("/preview", payload); +} + +export async function validateRecipe( + payload: unknown, +): Promise { + return postJson("/validate", payload); +} + +export async function listRecipeTools(payload: unknown): Promise { + return postJson("/tools", payload); +} diff --git a/studio/frontend/src/features/recipe-studio/components/chip-input.tsx b/studio/frontend/src/features/recipe-studio/components/chip-input.tsx index 931e734871..5162b73e03 100644 --- a/studio/frontend/src/features/recipe-studio/components/chip-input.tsx +++ b/studio/frontend/src/features/recipe-studio/components/chip-input.tsx @@ -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) => { - 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 ( -
- {values.map((value, index) => ( - - {value} - - - ))} - setDraft(event.target.value)} - onKeyDown={handleKeyDown} - /> -
- ); -} +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) => { + 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 ( +
+ {values.map((value, index) => ( + + {value} + + + ))} + 0 ? listId : undefined} + onChange={(event) => handleChange(event.target.value)} + onBlur={() => addValue(draft, false)} + onKeyDown={handleKeyDown} + /> + {suggestions && suggestions.length > 0 && ( + + {suggestions.map((value) => ( + + )} +
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx b/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx new file mode 100644 index 0000000000..796123ec89 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx @@ -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; + onUpdate: (patch: Partial) => 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 ( +
+ + onUpdate({ name: value })} /> +
+ +
+ onUpdate({ model_alias: value ?? "" })} + itemToStringValue={(value) => value} + autoHighlight={true} + > + { + const inputValue = event.target.value; + if (inputValue !== config.model_alias) { + onUpdate({ model_alias: inputValue }); + } + }} + /> + + No model configs found + + {(alias: string) => ( + + {alias} + + )} + + + +
+
+ {config.llm_type === "code" && ( +
+ + +
+ )} +
+ +