feat(recipe-studio): sanitize shared seed payload + add inline seed UX with HF search

This commit is contained in:
Shine1i 2026-02-26 12:23:05 +01:00
commit 04d6f5e67b
11 changed files with 297 additions and 62 deletions

View file

@ -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",

View file

@ -27,6 +27,9 @@ export function getConfigUiMode(
}
return "dialog";
}
if (config.kind === "seed") {
return "inline";
}
if (config.kind === "expression") {
return "inline";
}

View file

@ -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<SeedConfig>) => void;
};
export function InlineSeed({ config, onUpdate }: InlineSeedProps): ReactElement {
const mode = config.seed_source_type ?? "hf";
if (mode === "hf") {
return (
<div className="space-y-2">
<InlineField label="Dataset">
<HfDatasetCombobox
value={config.hf_repo_id}
accessToken={config.hf_token?.trim() || undefined}
onValueChange={(next) =>
onUpdate({
hf_repo_id: next,
hf_path: "",
seed_columns: [],
seed_drop_columns: [],
seed_preview_rows: [],
})
}
placeholder="org/repo"
/>
</InlineField>
<p className="text-[11px] text-muted-foreground">
Load columns in dialog.
</p>
</div>
);
}
const isLocal = mode === "local";
const fileName = isLocal
? config.local_file_name?.trim()
: config.unstructured_file_name?.trim();
return (
<div className="corner-squircle flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-2">
<div className="corner-squircle rounded-md bg-primary/10 p-1.5 text-primary">
<HugeiconsIcon
icon={isLocal ? DocumentCodeIcon : DocumentAttachmentIcon}
className="size-3.5"
/>
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium">
{fileName || "No file selected"}
</p>
<p className="text-[11px] text-muted-foreground">
{isLocal ? "Structured file" : "Unstructured document"} · configure in dialog
</p>
</div>
<HugeiconsIcon icon={Plant01Icon} className="ml-auto size-3.5 text-muted-foreground/60" />
</div>
);
}

View file

@ -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 <InlineExpression config={config} onUpdate={onUpdate} />;
}
if (config.kind === "seed") {
return <InlineSeed config={config} onUpdate={onUpdate} />;
}
}
if (config?.kind === "sampler" && config.sampler_type === "category") {

View file

@ -113,6 +113,15 @@ export function AvailableReferencesInline({
+{hiddenCount} more
</button>
)}
{expanded && collapsedCount < entries.length && (
<button
type="button"
className="corner-squircle h-4 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(false)}
>
Show less
</button>
)}
</div>
</div>
</div>

View file

@ -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<HTMLDivElement>(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 (
<div
ref={anchorRef}
className={className}
onKeyDown={(event) => {
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);
}
}}
>
<Combobox
items={items}
filteredItems={items}
filter={null}
value={value.trim() ? value : null}
onValueChange={(next) => onValueChange(next ?? "")}
onInputValueChange={(next) => {
if (selectingRef.current) {
selectingRef.current = false;
return;
}
setInputValue(next);
}}
itemToStringValue={(item) => item}
autoHighlight={true}
>
<ComboboxInput
id={inputId}
className="nodrag w-full"
placeholder={placeholder}
/>
<ComboboxContent anchor={anchorRef}>
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Spinner className="size-3.5" />
Searching...
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
)}
<ComboboxList>
{(id: string) => (
<ComboboxItem
key={id}
value={id}
onPointerDown={() => {
selectingRef.current = true;
}}
>
{id}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{error && (
<p className="mt-1 text-xs text-destructive">
{error}
</p>
)}
</div>
);
}

View file

@ -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)."
/>
<div className="flex items-center gap-2">
<Input
id={datasetId}
className="nodrag flex-1"
placeholder="org/repo"
<HfDatasetCombobox
inputId={datasetId}
className="flex-1"
value={config.hf_repo_id}
onChange={(event) =>
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
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<FieldLabel
label="Subset (optional)"
htmlFor={subsetId}
hint="Dataset config/subset name."
/>
<Input
id={subsetId}
className="nodrag"
placeholder="default"
value={config.hf_subset ?? ""}
onChange={(event) => onUpdate({ hf_subset: event.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Split"
htmlFor={splitId}
hint="Split to inspect (default train)."
/>
<Input
id={splitId}
className="nodrag"
placeholder="train"
value={config.hf_split ?? ""}
onChange={(event) => onUpdate({ hf_split: event.target.value })}
/>
</div>
</div>
</>
)}
{mode === "local" && (
<div className="grid gap-2">
<FieldLabel
label="Local file"
label="Structured file"
hint="Upload CSV, JSON, or JSONL seed file."
/>
<div className="flex items-center gap-2">

View file

@ -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<string, unknown>;
const recipe =
root.recipe && typeof root.recipe === "object"
? (root.recipe as Record<string, unknown>)
: null;
const ui =
root.ui && typeof root.ui === "object"
? (root.ui as Record<string, unknown>)
: null;
const seedConfig =
recipe?.seed_config && typeof recipe.seed_config === "object"
? (recipe.seed_config as Record<string, unknown>)
: null;
const source =
seedConfig?.source && typeof seedConfig.source === "object"
? (seedConfig.source as Record<string, unknown>)
: 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<void> => {
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.");

View file

@ -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<Node<RecipeNodeData | RecipeGraphAuxNodeData>, 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]);

View file

@ -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,
};
}

View file

@ -14,9 +14,6 @@ export function buildSeedConfig(
): Record<string, unknown> | 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;