diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts index 75d860290e..073cce928e 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts +++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts @@ -125,7 +125,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ { kind: "seed", type: "seed_local", - title: "Local file", + title: "Structured file", description: "Upload CSV/JSON/JSONL and use rows as seed context.", icon: DocumentCodeIcon, dialogKey: "seed", diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts b/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts index 97f441edce..17f22b6557 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts @@ -27,6 +27,9 @@ export function getConfigUiMode( } return "dialog"; } + if (config.kind === "seed") { + return "inline"; + } if (config.kind === "expression") { return "inline"; } diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-seed.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-seed.tsx new file mode 100644 index 0000000000..619faa7704 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-seed.tsx @@ -0,0 +1,66 @@ +import { DocumentAttachmentIcon, DocumentCodeIcon, Plant01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; +import type { SeedConfig } from "../../types"; +import { HfDatasetCombobox } from "../shared/hf-dataset-combobox"; +import { InlineField } from "./inline-field"; + +type InlineSeedProps = { + config: SeedConfig; + onUpdate: (patch: Partial) => void; +}; + +export function InlineSeed({ config, onUpdate }: InlineSeedProps): ReactElement { + const mode = config.seed_source_type ?? "hf"; + + if (mode === "hf") { + return ( +
+ + + onUpdate({ + hf_repo_id: next, + hf_path: "", + seed_columns: [], + seed_drop_columns: [], + seed_preview_rows: [], + }) + } + placeholder="org/repo" + /> + +

+ Load columns in dialog. +

+
+ ); + } + + const isLocal = mode === "local"; + const fileName = isLocal + ? config.local_file_name?.trim() + : config.unstructured_file_name?.trim(); + + return ( +
+
+ +
+
+

+ {fileName || "No file selected"} +

+

+ {isLocal ? "Structured file" : "Unstructured document"} ยท configure in dialog +

+
+ +
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx index e5c8a5d3f0..4d1bbeedc5 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx @@ -42,6 +42,7 @@ import { InlineLlm } from "./inline/inline-llm"; import { InlineModel } from "./inline/inline-model"; import { isInlineConfig } from "./inline/inline-policy"; import { InlineSampler } from "./inline/inline-sampler"; +import { InlineSeed } from "./inline/inline-seed"; import { BaseNode, BaseNodeContent, @@ -218,7 +219,7 @@ function getConfigSummary(config: NodeConfig | undefined): string { return "Set HF dataset repo"; } if (seedSourceType === "local") { - return "Upload CSV/JSON file"; + return "Upload structured file"; } return "Upload PDF/DOCX/TXT file"; } @@ -257,6 +258,9 @@ function renderNodeBody( if (config.kind === "expression") { return ; } + if (config.kind === "seed") { + return ; + } } if (config?.kind === "sampler" && config.sampler_type === "category") { diff --git a/studio/frontend/src/features/recipe-studio/components/shared/available-references-inline.tsx b/studio/frontend/src/features/recipe-studio/components/shared/available-references-inline.tsx index 2e8b17db27..938e5405ab 100644 --- a/studio/frontend/src/features/recipe-studio/components/shared/available-references-inline.tsx +++ b/studio/frontend/src/features/recipe-studio/components/shared/available-references-inline.tsx @@ -113,6 +113,15 @@ export function AvailableReferencesInline({ +{hiddenCount} more )} + {expanded && collapsedCount < entries.length && ( + + )} diff --git a/studio/frontend/src/features/recipe-studio/components/shared/hf-dataset-combobox.tsx b/studio/frontend/src/features/recipe-studio/components/shared/hf-dataset-combobox.tsx new file mode 100644 index 0000000000..7377bcac69 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/shared/hf-dataset-combobox.tsx @@ -0,0 +1,122 @@ +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Spinner } from "@/components/ui/spinner"; +import { useDebouncedValue, useHfDatasetSearch } from "@/hooks"; +import { type ReactElement, useEffect, useMemo, useRef, useState } from "react"; + +type HfDatasetComboboxProps = { + value: string; + onValueChange: (value: string) => void; + accessToken?: string; + inputId?: string; + placeholder?: string; + className?: string; +}; + +export function HfDatasetCombobox({ + value, + onValueChange, + accessToken, + inputId, + placeholder = "Search datasets...", + className, +}: HfDatasetComboboxProps): ReactElement { + const [inputValue, setInputValue] = useState(value); + const selectingRef = useRef(false); + const anchorRef = useRef(null); + const debouncedQuery = useDebouncedValue(inputValue); + + useEffect(() => { + setInputValue(value); + }, [value]); + + const { results, isLoading, error } = useHfDatasetSearch(debouncedQuery, { + accessToken, + }); + + const items = useMemo(() => { + const ids = results.map((item) => item.id); + const selected = value.trim(); + if (selected && !ids.includes(selected)) { + ids.push(selected); + } + return ids; + }, [results, value]); + + return ( +
{ + if (event.key !== "Enter") return; + if (!(event.target instanceof HTMLInputElement)) return; + event.preventDefault(); + if (items.length > 0) { + onValueChange(items[0]); + return; + } + const typed = event.target.value.trim(); + if (typed) { + onValueChange(typed); + } + }} + > + onValueChange(next ?? "")} + onInputValueChange={(next) => { + if (selectingRef.current) { + selectingRef.current = false; + return; + } + setInputValue(next); + }} + itemToStringValue={(item) => item} + autoHighlight={true} + > + + + {isLoading ? ( +
+ + Searching... +
+ ) : ( + No datasets found + )} + + {(id: string) => ( + { + selectingRef.current = true; + }} + > + {id} + + )} + +
+
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx index c60ee4a5db..1959c28c45 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx @@ -45,6 +45,7 @@ import type { SeedSamplingStrategy, SeedSelectionType, } from "../../types"; +import { HfDatasetCombobox } from "../../components/shared/hf-dataset-combobox"; import { FieldLabel } from "../shared/field-label"; const SAMPLING_OPTIONS: Array<{ value: SeedSamplingStrategy; label: string }> = [ @@ -209,8 +210,6 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl const samplingId = `${config.id}-sampling`; const selectionId = `${config.id}-selection`; const tokenId = `${config.id}-hf-token`; - const subsetId = `${config.id}-hf-subset`; - const splitId = `${config.id}-hf-split`; const datasetId = `${config.id}-hf-dataset`; const chunkSizeId = `${config.id}-chunk-size`; const chunkOverlapId = `${config.id}-chunk-overlap`; @@ -221,10 +220,8 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl if (mode === "hf") { const dataset = config.hf_repo_id.trim(); if (!dataset) return null; - const subset = config.hf_subset?.trim() ?? ""; - const split = config.hf_split?.trim() || "train"; const token = config.hf_token?.trim() ?? ""; - return `hf:${dataset}|${subset}|${split}|${token}`; + return `hf:${dataset}|${token}`; } if (mode === "local") { if (!localFile) return null; @@ -255,8 +252,8 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl const response = await inspectSeedDataset({ dataset_name: datasetName, hf_token: config.hf_token?.trim() || undefined, - subset: config.hf_subset || undefined, - split: config.hf_split || "train", + subset: undefined, + split: "train", preview_size: 10, }); onUpdate({ @@ -266,8 +263,8 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl response.columns.includes(name), ), seed_preview_rows: response.preview_rows ?? [], - hf_split: response.split ?? config.hf_split ?? "", - hf_subset: response.subset ?? config.hf_subset ?? "", + hf_split: "", + hf_subset: "", local_file_name: "", unstructured_file_name: "", }); @@ -416,14 +413,17 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl hint="Hugging Face dataset repo id (org/repo)." />
- + accessToken={config.hf_token?.trim() || undefined} + placeholder="org/repo" + onValueChange={(nextValue) => onUpdate({ - hf_repo_id: event.target.value, + hf_repo_id: nextValue, + hf_subset: "", + hf_split: "", hf_path: "", seed_columns: [], seed_drop_columns: [], @@ -458,43 +458,13 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl />
-
-
- - onUpdate({ hf_subset: event.target.value })} - /> -
-
- - onUpdate({ hf_split: event.target.value })} - /> -
-
)} {mode === "local" && (
diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts index 0d15ca27b2..740ab54c74 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts @@ -64,6 +64,58 @@ function stripApiKeys(value: unknown): unknown { return output; } +function sanitizeSeedForShare(payload: unknown): unknown { + if (!payload || typeof payload !== "object") { + return payload; + } + const root = payload as Record; + const recipe = + root.recipe && typeof root.recipe === "object" + ? (root.recipe as Record) + : null; + const ui = + root.ui && typeof root.ui === "object" + ? (root.ui as Record) + : null; + + const seedConfig = + recipe?.seed_config && typeof recipe.seed_config === "object" + ? (recipe.seed_config as Record) + : null; + const source = + seedConfig?.source && typeof seedConfig.source === "object" + ? (seedConfig.source as Record) + : null; + + if (source && "token" in source) { + delete source.token; + } + + const uiSourceType = + typeof ui?.seed_source_type === "string" ? ui.seed_source_type : null; + const sourceType = + typeof source?.seed_type === "string" ? source.seed_type : null; + const shouldResetLocalState = + sourceType === "local" || + uiSourceType === "local" || + uiSourceType === "unstructured"; + + if (shouldResetLocalState) { + if (source && "path" in source) { + source.path = ""; + } + if (ui) { + ui.seed_columns = []; + ui.seed_drop_columns = []; + ui.seed_preview_rows = []; + ui.local_file_name = ""; + ui.unstructured_file_name = ""; + } + } + + return root; +} + export function useRecipePersistence({ recipeId, initialRecipeName, @@ -160,7 +212,7 @@ export function useRecipePersistence({ const copyRecipe = useCallback(async (): Promise => { setCopied(false); try { - const safePayload = stripApiKeys(payloadResult.payload); + const safePayload = sanitizeSeedForShare(stripApiKeys(payloadResult.payload)); const ok = await copyTextToClipboard(JSON.stringify(safePayload, null, 2)); if (!ok) { throw new Error("Clipboard not available."); diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index f225dc2846..6ba2a3e23a 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -22,6 +22,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -172,6 +173,7 @@ export function RecipeStudioPage({ const [reactFlowInstance, setReactFlowInstance] = useState< ReactFlowInstance, Edge> | null >(null); + const lastProcessedFitTickRef = useRef(0); const handleExecutionStart = useCallback(() => { setActiveView("executions"); }, []); @@ -365,15 +367,22 @@ export function RecipeStudioPage({ runDialogKind === "preview" ? previewLoading : fullLoading; useEffect(() => { - if (!reactFlowInstance || activeView !== "editor" || fitViewTick === 0) { + if (!reactFlowInstance || fitViewTick === 0 || activeView !== "editor") { return; } + if (lastProcessedFitTickRef.current === fitViewTick) { + return; + } + lastProcessedFitTickRef.current = fitViewTick; let frame2 = 0; + let frame3 = 0; const frame1 = window.requestAnimationFrame(() => { frame2 = window.requestAnimationFrame(() => { - reactFlowInstance.fitView({ - duration: 250, - nodes: getFitNodeIdsIgnoringNotes(reactFlowInstance.getNodes()), + frame3 = window.requestAnimationFrame(() => { + reactFlowInstance.fitView({ + duration: 320, + nodes: getFitNodeIdsIgnoringNotes(reactFlowInstance.getNodes()), + }); }); }); }); @@ -382,6 +391,9 @@ export function RecipeStudioPage({ if (frame2) { window.cancelAnimationFrame(frame2); } + if (frame3) { + window.cancelAnimationFrame(frame3); + } }; }, [activeView, fitViewTick, reactFlowInstance]); diff --git a/studio/frontend/src/features/recipe-studio/utils/node-data.ts b/studio/frontend/src/features/recipe-studio/utils/node-data.ts index 96e9a0f4e2..76b1f5ad89 100644 --- a/studio/frontend/src/features/recipe-studio/utils/node-data.ts +++ b/studio/frontend/src/features/recipe-studio/utils/node-data.ts @@ -41,18 +41,18 @@ export function nodeDataFromConfig( } if (config.kind === "seed") { const seedSourceType = config.seed_source_type ?? "hf"; - const subtype = + const sourceLabel = seedSourceType === "hf" - ? "Hugging Face" + ? "Hugging Face dataset" : seedSourceType === "local" - ? "Local File" - : "Unstructured"; + ? "Structured file" + : "Unstructured document"; return { title: "Seed", kind: "seed", - subtype, + subtype: sourceLabel, blockType: "seed", - name: config.name, + name: sourceLabel, layoutDirection, }; } diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts index 464f770a0c..a07e6c2ee8 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts @@ -14,9 +14,6 @@ export function buildSeedConfig( ): Record | undefined { const seedSourceType = config.seed_source_type ?? "hf"; const path = config.hf_path.trim(); - if (!path) { - return undefined; - } const endpoint = config.hf_endpoint?.trim() || "https://huggingface.co"; const token = config.hf_token?.trim() || null;