diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts index e4dbd38e2a..889061c931 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts +++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts @@ -72,7 +72,7 @@ export type BlockDefinition = { export const BLOCK_GROUPS: BlockGroup[] = [ { kind: "sampler", - title: "Sampler", + title: "Samplers", description: "Numeric + categorical blocks.", icon: DiceFaces03Icon, }, @@ -84,8 +84,8 @@ export const BLOCK_GROUPS: BlockGroup[] = [ }, { kind: "llm", - title: "LLM", - description: "Text + structured blocks.", + title: "LLM + Models", + description: "Generation, providers, and model aliases.", icon: PencilEdit02Icon, }, { @@ -182,7 +182,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ kind: "sampler", type: "person", title: "Person", - description: "Synthetic person sampler.", + description: "Faker person sampler.", icon: UserAccountIcon, dialogKey: "person", createConfig: (id, existing) => makeSamplerConfig(id, "person", existing), @@ -227,7 +227,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ kind: "llm", type: "model_provider", title: "Model Provider", - description: "Configure API endpoint + key.", + description: "Endpoint, auth, and provider settings.", icon: Shield02Icon, dialogKey: "model_provider", createConfig: (id, existing) => makeModelProviderConfig(id, existing), @@ -236,7 +236,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ kind: "llm", type: "model_config", title: "Model Config", - description: "Alias + model + inference params.", + description: "Alias, model, provider, and inference params.", icon: Plant01Icon, dialogKey: "model_config", createConfig: (id, existing) => makeModelConfig(id, existing), @@ -291,4 +291,3 @@ export function getBlockDefinitionForConfig( } return getBlockDefinition("expression", "expression"); } - diff --git a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx index bcfdd3b0a8..9192bb1cac 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx @@ -87,6 +87,7 @@ export function renderBlockDialog( ) : null; diff --git a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx index d5c6d95e7a..abb4112444 100644 --- a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx +++ b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx @@ -36,6 +36,8 @@ type BlockSheetProps = { container: HTMLDivElement | null; sheetView: SheetView; onViewChange: (sheetView: SheetView) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; onAddSampler: (type: SamplerType) => void; onAddSeed: () => void; onAddLlm: (type: LlmType) => void; @@ -128,6 +130,8 @@ export function BlockSheet({ container, sheetView, onViewChange, + open, + onOpenChange, onAddSampler, onAddSeed, onAddLlm, @@ -140,16 +144,26 @@ export function BlockSheet({ onImport, }: BlockSheetProps): ReactElement { const sheetTitle = getSheetTitle(sheetView); - const [open, setOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const expressionBlocks = useMemo(() => getBlocksForKind("expression"), []); const seedBlocks = useMemo(() => getBlocksForKind("seed"), []); + const isControlled = typeof open === "boolean"; + const sheetOpen = isControlled ? (open as boolean) : uncontrolledOpen; + + const setSheetOpen = (nextOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }; + return (
{ - setOpen(open); - if (open) { + open={sheetOpen} + onOpenChange={(nextOpen) => { + setSheetOpen(nextOpen); + if (nextOpen) { onViewChange("root"); } }} @@ -201,17 +215,17 @@ export function BlockSheet({ isActive={index === 0} onClick={() => { if (item.kind === "processor") { - setOpen(false); + setSheetOpen(false); onOpenProcessors(); return; } if (item.kind === "seed" && seedBlocks.length === 1) { - setOpen(false); + setSheetOpen(false); onAddSeed(); return; } if (item.kind === "expression" && expressionBlocks.length === 1) { - setOpen(false); + setSheetOpen(false); onAddExpression(); return; } 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 1afd16c22c..41324d9631 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 @@ -253,13 +253,13 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle key={item.id} className="pointer-events-none relative flex min-w-[80px] flex-1 justify-center pt-2" > - + {item.label}
))} @@ -275,7 +275,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle id={item.id} type="target" position={Position.Left} - className="pointer-events-auto !size-2 !border-border !bg-background" + className="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20" style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }} /> @@ -383,7 +383,7 @@ function RecipeGraphNodeBase({ position={dataInPosition} className="absolute inset-0 pointer-events-none" labelClassName="sr-only" - handleClassName="pointer-events-auto !size-2 !border-border !bg-background" + handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20" /> )} @@ -405,7 +405,7 @@ function RecipeGraphNodeBase({ position={semanticInPosition} className="absolute inset-0 pointer-events-none" labelClassName="sr-only" - handleClassName="pointer-events-auto !size-2 !border-border !bg-background" + handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20" /> )} @@ -417,7 +417,7 @@ function RecipeGraphNodeBase({ position={semanticOutPosition} className="absolute inset-0 pointer-events-none" labelClassName="sr-only" - handleClassName="pointer-events-auto !size-2 !border-border !bg-background" + handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20" /> )} diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx index 14a1f98dea..16ecc33553 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx @@ -11,6 +11,7 @@ export const RecipeGraphSemanticEdge = memo(function RecipeGraphSemanticEdge({ targetPosition, style, markerEnd, + selected, }: EdgeProps): ReactElement { const [path] = getSmoothStepPath({ sourceX, @@ -29,9 +30,12 @@ export const RecipeGraphSemanticEdge = memo(function RecipeGraphSemanticEdge({ path={path} markerEnd={markerEnd} style={{ - strokeDasharray: "4 4", - strokeWidth: 1.5, - stroke: "var(--muted-foreground)", + strokeDasharray: selected ? "7 5" : "6 5", + strokeWidth: selected ? 2.3 : 1.8, + stroke: selected + ? "hsl(var(--primary) / 0.9)" + : "hsl(var(--foreground) / 0.38)", + opacity: selected ? 1 : 0.92, ...style, }} /> diff --git a/studio/frontend/src/features/recipe-studio/components/rf-ui/base-handle.tsx b/studio/frontend/src/features/recipe-studio/components/rf-ui/base-handle.tsx index 4ab56ed844..1901b141f1 100644 --- a/studio/frontend/src/features/recipe-studio/components/rf-ui/base-handle.tsx +++ b/studio/frontend/src/features/recipe-studio/components/rf-ui/base-handle.tsx @@ -14,7 +14,7 @@ export function BaseHandle({ diff --git a/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx b/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx index 3364179288..e4c9bc2deb 100644 --- a/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx +++ b/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx @@ -17,6 +17,7 @@ export function DataEdge({ data = { path: "auto" }, id, markerEnd, + selected, sourcePosition, sourceX, sourceY, @@ -44,8 +45,17 @@ export function DataEdge({ targetPosition, }); + const edgeStyle = { + stroke: selected + ? "hsl(var(--primary) / 0.92)" + : "hsl(var(--foreground) / 0.42)", + strokeWidth: selected ? 2.6 : 2.1, + opacity: selected ? 1 : 0.92, + ...style, + }; + return ( - + ); } diff --git a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx index fe50734eb6..d0680641fa 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; import type { ReactElement } from "react"; +import { getBlockDefinitionForConfig } from "../blocks/definitions"; import { renderBlockDialog } from "../blocks/registry"; import type { NodeConfig, SamplerConfig } from "../types"; import { DialogShell } from "./shared/dialog-shell"; @@ -30,6 +31,8 @@ export function ConfigDialog({ onUpdate, container, }: ConfigDialogProps): ReactElement { + const blockDefinition = getBlockDefinitionForConfig(config); + return ( - + {!config && (
Select a node to edit. 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 index 796123ec89..18d1881227 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx @@ -41,6 +41,7 @@ const CODE_LANG_OPTIONS = [ type LlmGeneralTabProps = { config: LlmConfig; modelConfigAliases: string[]; + modelProviderOptions: string[]; modelAliasAnchorRef: RefObject; onUpdate: (patch: Partial) => void; }; @@ -48,6 +49,7 @@ type LlmGeneralTabProps = { export function LlmGeneralTab({ config, modelConfigAliases, + modelProviderOptions, modelAliasAnchorRef, onUpdate, }: LlmGeneralTabProps): ReactElement { @@ -56,11 +58,22 @@ export function LlmGeneralTab({ const promptId = `${config.id}-prompt`; const outputFormatId = `${config.id}-output-format`; const systemPromptId = `${config.id}-system-prompt`; + const hasModelConfigs = modelConfigAliases.length > 0; + const hasModelProviders = modelProviderOptions.length > 0; return (
onUpdate({ name: value })} /> + {(!hasModelConfigs || !hasModelProviders) && ( +
+

Setup hint

+

+ {!hasModelProviders && "Add a Model Provider block. "} + {!hasModelConfigs && "Add a Model Config block and pick its alias here."} +

+
+ )}
-
-

- Weights (optional) -

-
- {(config.values ?? []).map((value, index) => ( -
- - {value} - - { - const weights = [...(config.weights ?? [])]; - weights[index] = event.target.value - ? Number(event.target.value) - : null; - onUpdate({ weights }); - }} - /> -
- ))} -
-
-
-
-

- Conditional params (category) -

- - {Object.keys(conditional).length} rules - -
-
- setConditionDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - handleAddCondition(); - } - }} - /> - -
- {Object.entries(conditional).map(([condition, params]) => ( -
-
-

{condition}

- -
- { - const values = [...(params.values ?? []), value]; - const weights = [...(params.weights ?? []), null]; - onUpdate({ - // biome-ignore lint/style/useNamingConvention: api schema - conditional_params: { - ...conditional, - [condition]: { ...params, values, weights }, - }, - }); - }} - onRemove={(index) => { - const values = [...(params.values ?? [])]; - const weights = [...(params.weights ?? [])]; - values.splice(index, 1); - weights.splice(index, 1); - onUpdate({ - // biome-ignore lint/style/useNamingConvention: api schema - conditional_params: { - ...conditional, - [condition]: { ...params, values, weights }, - }, - }); - }} - placeholder="Type a conditional value and press Enter" - /> + +
+ + + +

- Rule weights (optional) + Weights (optional)

-
- {(params.values ?? []).map((value, index) => ( -
- - {value} - - { - const weights = [ - ...(params.weights ?? - Array.from( - { length: (params.values ?? []).length }, - () => null, - )), - ]; - weights[index] = event.target.value - ? Number(event.target.value) - : null; - onUpdate({ - // biome-ignore lint/style/useNamingConvention: api schema - conditional_params: { - ...conditional, - [condition]: { ...params, weights }, - }, - }); - }} - /> -
- ))} -
+ {(config.values ?? []).length === 0 ? ( +

+ Add values first, then set optional weights. +

+ ) : ( +
+ {(config.values ?? []).map((value, index) => ( +
+ + {value} + + { + const weights = [...(config.weights ?? [])]; + weights[index] = event.target.value + ? Number(event.target.value) + : null; + onUpdate({ weights }); + }} + /> +
+ ))} +
+ )}
-
- ))} -
+
+

+ Conditional params (category) +

+ + {conditionalCount} rules + +
+
+ setConditionDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddCondition(); + } + }} + /> + +
+ {Object.entries(conditional).map(([condition, params]) => ( +
+
+

{condition}

+ +
+ { + const { values, weights } = addChipWithWeight( + params.values, + params.weights, + value, + ); + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: { + ...conditional, + [condition]: { ...params, values, weights }, + }, + }); + }} + onRemove={(index) => { + const { values, weights } = removeChipWithWeight( + params.values, + params.weights, + index, + ); + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: { + ...conditional, + [condition]: { ...params, values, weights }, + }, + }); + }} + placeholder="Type a conditional value and press Enter" + /> +
+

+ Rule weights (optional) +

+
+ {(params.values ?? []).map((value, index) => ( +
+ + {value} + + { + const weights = [ + ...(params.weights ?? + Array.from( + { length: (params.values ?? []).length }, + () => null, + )), + ]; + weights[index] = event.target.value + ? Number(event.target.value) + : null; + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + conditional_params: { + ...conditional, + [condition]: { ...params, weights }, + }, + }); + }} + /> +
+ ))} +
+
+
+ ))} + +
+
); } diff --git a/studio/frontend/src/features/recipe-studio/dialogs/samplers/person-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/samplers/person-dialog.tsx index f3c7c10b66..b2436d1adc 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/samplers/person-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/samplers/person-dialog.tsx @@ -6,8 +6,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import type { ReactElement } from "react"; +import { type ReactElement, useEffect } from "react"; import type { SamplerConfig } from "../../types"; import { NameField } from "../shared/name-field"; @@ -24,13 +23,23 @@ export function PersonDialog({ const sexId = `${config.id}-person-sex`; const ageRangeId = `${config.id}-person-age-range`; const cityId = `${config.id}-person-city`; - const sourceId = `${config.id}-person-source`; + const updateField = ( key: K, value: SamplerConfig[K], ) => { onUpdate({ [key]: value } as Partial); }; + + useEffect(() => { + if (config.sampler_type !== "person_from_faker") { + onUpdate({ + sampler_type: "person_from_faker", + person_with_synthetic_personas: undefined, + }); + } + }, [config.sampler_type, onUpdate]); + return (
onUpdate({ name: value })} />
-
-
); diff --git a/studio/frontend/src/features/recipe-studio/dialogs/shared/dialog-shell.tsx b/studio/frontend/src/features/recipe-studio/dialogs/shared/dialog-shell.tsx index e55b1cab74..cbfe5b6d69 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/shared/dialog-shell.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/shared/dialog-shell.tsx @@ -5,13 +5,19 @@ import { } from "@/components/ui/dialog"; import type { ReactElement } from "react"; -export function DialogShell(): ReactElement { +type DialogShellProps = { + title?: string; + description?: string; +}; + +export function DialogShell({ + title = "Configure block", + description = "Adjust block params before running the flow.", +}: DialogShellProps): ReactElement { return ( - Configure block - - Adjust block params before running the flow. - + {title} + {description} ); } 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 884637dc49..6123215fef 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -10,6 +10,8 @@ import { Panel, ReactFlow, } from "@xyflow/react"; +import { PlusSignIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { type ReactElement, useCallback, @@ -157,6 +159,7 @@ export function RecipeStudioPage({ const [sheetContainer, setSheetContainer] = useState( null, ); + const [blockSheetOpen, setBlockSheetOpen] = useState(false); const [activeView, setActiveView] = useState("editor"); const [processorsOpen, setProcessorsOpen] = useState(false); const [interactive, setInteractive] = useState(true); @@ -316,6 +319,11 @@ export function RecipeStudioPage({ setProcessorsOpen(true); }, [processors, setProcessors]); + const openRootBlockSheet = useCallback(() => { + setSheetView("root"); + setBlockSheetOpen(true); + }, [setSheetView]); + return (
@@ -375,11 +383,37 @@ export function RecipeStudioPage({ size={1} color="#d4d4d8" /> + {nodes.length === 0 && ( +
+ +
+ )}