feat(recipe-studio): sanitize shared seed payload + add inline seed UX with HF search
This commit is contained in:
parent
75857cdcea
commit
04d6f5e67b
11 changed files with 297 additions and 62 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ export function getConfigUiMode(
|
|||
}
|
||||
return "dialog";
|
||||
}
|
||||
if (config.kind === "seed") {
|
||||
return "inline";
|
||||
}
|
||||
if (config.kind === "expression") {
|
||||
return "inline";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue