From 175fd0459cc0ec36d757b53bb67c38465d93b5c6 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Sat, 14 Feb 2026 16:30:01 +0100 Subject: [PATCH] feat: add Jinja reference autocomplete components and enhance graph edges styling --- studio/frontend/package.json | 72 +-- studio/frontend/src/components/ui/chart.tsx | 64 +-- studio/frontend/src/features/chat/adapter.ts | 4 +- .../recipe-studio/components/block-sheet.tsx | 20 +- .../components/graph/internals-sync.tsx | 13 +- .../components/inline/inline-expression.tsx | 14 +- .../jinja/jinja-ref-autocomplete.tsx | 461 ++++++++++++++++++ .../components/recipe-graph-aux-node.tsx | 29 +- .../components/rf-ui/data-edge.tsx | 41 +- .../dialogs/expression/expression-dialog.tsx | 11 +- .../recipe-studio/dialogs/llm/general-tab.tsx | 15 +- .../recipe-studio/recipe-studio-page.tsx | 14 +- .../recipe-studio/stores/recipe-studio.ts | 6 + .../utils/graph/derive-display-graph.ts | 41 +- .../recipe-studio/utils/import/edges.ts | 18 +- .../features/recipe-studio/utils/variables.ts | 102 +++- 16 files changed, 745 insertions(+), 180 deletions(-) create mode 100644 studio/frontend/src/features/recipe-studio/components/jinja/jinja-ref-autocomplete.tsx diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 13bc323307..273f9e8ae9 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -13,75 +13,75 @@ "biome:fix": "biome check . --write" }, "dependencies": { - "@assistant-ui/react": "^0.12.3", - "@assistant-ui/react-markdown": "^0.12.1", - "@assistant-ui/react-streamdown": "^0.1.0", - "@base-ui/react": "^1.1.0", - "@dagrejs/dagre": "^2.0.3", + "@assistant-ui/react": "^0.12.10", + "@assistant-ui/react-markdown": "^0.12.3", + "@assistant-ui/react-streamdown": "^0.1.2", + "@base-ui/react": "^1.2.0", + "@dagrejs/dagre": "^2.0.4", "@fontsource-variable/figtree": "^5.2.10", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/react": "^1.1.4", - "@huggingface/hub": "^2.8.0", + "@hugeicons/react": "^1.1.5", + "@huggingface/hub": "^2.9.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", - "@streamdown/cjk": "^1.0.1", - "@streamdown/code": "^1.0.1", - "@streamdown/math": "^1.0.1", - "@streamdown/mermaid": "^1.0.1", - "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-router": "^1.156.0", + "@streamdown/cjk": "^1.0.2", + "@streamdown/code": "^1.0.2", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-router": "^1.159.10", "@toolwind/corner-shape": "^0.0.8-3", "@types/canvas-confetti": "^1.9.0", "@xyflow/react": "^12.10.0", - "assistant-stream": "^0.3.0", + "assistant-stream": "^0.3.2", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", - "dexie": "^4.2.1", - "framer-motion": "^11.15.0", - "katex": "^0.16.22", + "dexie": "^4.3.0", + "framer-motion": "^11.18.2", + "katex": "^0.16.28", "lucide-react": "^0.563.0", "mammoth": "^1.11.0", - "motion": "^12.29.2", + "motion": "^12.34.0", "next": "^16.1.6", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", - "react": "^19.2.0", - "react-day-picker": "^9.13.0", - "react-dom": "^19.2.0", - "react-resizable-panels": "^4.4.1", - "recharts": "2.15.4", + "react": "^19.2.4", + "react-day-picker": "^9.13.2", + "react-dom": "^19.2.4", + "react-resizable-panels": "^4.6.4", + "recharts": "3.7.0", "remark-gfm": "^4.0.1", - "shadcn": "^3.7.0", + "shadcn": "^3.8.4", "sonner": "^2.0.7", - "streamdown": "^2.1.0", + "streamdown": "^2.2.0", "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", - "tw-shimmer": "^0.4.4", + "tw-shimmer": "^0.4.6", "unpdf": "^1.4.0", - "zustand": "^5.0.10" + "zustand": "^5.0.11" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@types/node": "^24.10.1", - "@types/react": "^19.2.5", + "@eslint/js": "^9.39.2", + "@types/node": "^24.10.13", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^5.1.4", "@biomejs/biome": "^1.9.4", - "eslint": "^9.39.1", + "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "typescript-eslint": "^8.55.0", + "vite": "^7.3.1" } } diff --git a/studio/frontend/src/components/ui/chart.tsx b/studio/frontend/src/components/ui/chart.tsx index 98dd00f6aa..071148982f 100644 --- a/studio/frontend/src/components/ui/chart.tsx +++ b/studio/frontend/src/components/ui/chart.tsx @@ -100,30 +100,30 @@ ${colorConfig ); }; -const ChartTooltip = RechartsPrimitive.Tooltip; - -function ChartTooltipContent({ - active, - payload, - className, +const ChartTooltip = RechartsPrimitive.Tooltip; + +function ChartTooltipContent({ + active, + payload, + className, indicator = "dot", hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName, - formatter, - color, - nameKey, - labelKey, -}: React.ComponentProps & - React.ComponentProps<"div"> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: "line" | "dot" | "dashed"; - nameKey?: string; - labelKey?: string; - }) { + formatter, + color, + nameKey, + labelKey, +}: Partial> & + React.ComponentProps<"div"> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + }) { const { config } = useChart(); const tooltipLabel = React.useMemo(() => { @@ -248,20 +248,20 @@ function ChartTooltipContent({ ); } -const ChartLegend = RechartsPrimitive.Legend; - -function ChartLegendContent({ - className, - hideIcon = false, - payload, - verticalAlign = "bottom", - nameKey, -}: React.ComponentProps<"div"> & - Pick & { - hideIcon?: boolean; - nameKey?: string; - }) { - const { config } = useChart(); +const ChartLegend = RechartsPrimitive.Legend; + +function ChartLegendContent({ + className, + hideIcon = false, + payload, + verticalAlign = "bottom", + nameKey, +}: React.ComponentProps<"div"> & + Pick & { + hideIcon?: boolean; + nameKey?: string; + }) { + const { config } = useChart(); if (!payload?.length) { return null; diff --git a/studio/frontend/src/features/chat/adapter.ts b/studio/frontend/src/features/chat/adapter.ts index 08e4da9c6d..a922e834cd 100644 --- a/studio/frontend/src/features/chat/adapter.ts +++ b/studio/frontend/src/features/chat/adapter.ts @@ -10,8 +10,8 @@ function collectTextParts(message: RunMessage): string[] { .filter((c) => c.type === "text") .map((c) => c.text); - if ("attachments" in message && (message.attachments?.length ?? 0) > 0) { - for (const att of message.attachments) { + if ("attachments" in message) { + for (const att of message.attachments ?? []) { for (const part of att.content ?? []) { if (part.type === "text") { textParts.push(part.text); 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 4c2e212531..fcd0f87cb5 100644 --- a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx +++ b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx @@ -17,7 +17,7 @@ import { Upload01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import type { ReactElement } from "react"; +import { type ReactElement, useMemo, useState } from "react"; import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "./recipe-floating-icon-button-class"; import type { LlmType, SamplerType } from "../types"; import { BLOCK_GROUPS, getBlocksForKind } from "../blocks/registry"; @@ -134,10 +134,14 @@ export function BlockSheet({ onImport, }: BlockSheetProps): ReactElement { const sheetTitle = getSheetTitle(sheetView); + const [open, setOpen] = useState(false); + const expressionBlocks = useMemo(() => getBlocksForKind("expression"), []); return (
{ + setOpen(open); if (open) { onViewChange("root"); } @@ -188,7 +192,19 @@ export function BlockSheet({ title={item.title} description={item.description} isActive={index === 0} - onClick={() => onViewChange(item.kind)} + onClick={() => { + if (item.kind === "processor") { + setOpen(false); + onOpenProcessors(); + return; + } + if (item.kind === "expression" && expressionBlocks.length === 1) { + setOpen(false); + onAddExpression(); + return; + } + onViewChange(item.kind); + }} /> ))} {sheetView === "processor" && ( diff --git a/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx b/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx index 69a8d45dfa..5fe6252a4b 100644 --- a/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx +++ b/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx @@ -1,5 +1,5 @@ import { useUpdateNodeInternals } from "@xyflow/react"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; type InternalsSyncProps = { nodeIds: string[]; @@ -7,18 +7,17 @@ type InternalsSyncProps = { export function InternalsSync({ nodeIds }: InternalsSyncProps): null { const updateNodeInternals = useUpdateNodeInternals(); + const idsKey = useMemo(() => nodeIds.join("|"), [nodeIds]); + const stableNodeIds = useMemo(() => nodeIds, [idsKey]); useEffect(() => { - if (nodeIds.length === 0) { + if (!idsKey) { return; } requestAnimationFrame(() => { - updateNodeInternals(nodeIds); - requestAnimationFrame(() => { - updateNodeInternals(nodeIds); - }); + updateNodeInternals(stableNodeIds); }); - }, [nodeIds, updateNodeInternals]); + }, [idsKey, stableNodeIds, updateNodeInternals]); return null; } diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx index 6f754e04d6..90dd3c666b 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx @@ -1,5 +1,4 @@ import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -10,7 +9,8 @@ import { import type { ReactElement } from "react"; import { useRecipeStudioStore } from "../../stores/recipe-studio"; import type { ExpressionConfig, ExpressionDtype } from "../../types"; -import { getAvailableVariables } from "../../utils/variables"; +import { getAvailableRefItems } from "../../utils/variables"; +import { JinjaRefInput } from "../jinja/jinja-ref-autocomplete"; import { InlineField } from "./inline-field"; type InlineExpressionProps = { @@ -25,7 +25,9 @@ export function InlineExpression({ onUpdate, }: InlineExpressionProps): ReactElement { const configs = useRecipeStudioStore((state) => state.configs); - const vars = getAvailableVariables(configs, config.id); + const flowMoving = useRecipeStudioStore((state) => state.flowMoving); + const items = getAvailableRefItems(configs, config.id); + const vars = items.map((item) => item.ref); return (
@@ -50,11 +52,13 @@ export function InlineExpression({ - onUpdate({ expr: event.target.value })} + items={items} + suppress={flowMoving} + onValueChange={(value) => onUpdate({ expr: value })} />
diff --git a/studio/frontend/src/features/recipe-studio/components/jinja/jinja-ref-autocomplete.tsx b/studio/frontend/src/features/recipe-studio/components/jinja/jinja-ref-autocomplete.tsx new file mode 100644 index 0000000000..6a51eb4f45 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/jinja/jinja-ref-autocomplete.tsx @@ -0,0 +1,461 @@ +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + BalanceScaleIcon, + Clock01Icon, + CodeIcon, + CodeSimpleIcon, + EqualSignIcon, + FingerPrintIcon, + FunctionIcon, + Parabola02Icon, + PencilEdit02Icon, + Tag01Icon, + TagsIcon, + UserAccountIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + type ChangeEvent, + type FocusEvent, + type ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { AvailableRefItem } from "../../utils/variables"; + +type CaretAnchor = { x: number; y: number; height: number }; + +const MAX_RESULTS = 50; + +function isInViewport(el: HTMLElement): boolean { + const boundsEl = el.closest(".react-flow") as HTMLElement | null; + const bounds = boundsEl?.getBoundingClientRect() ?? { + left: 0, + top: 0, + right: window.innerWidth, + bottom: window.innerHeight, + }; + const rect = el.getBoundingClientRect(); + return ( + rect.bottom >= bounds.top && + rect.top <= bounds.bottom && + rect.right >= bounds.left && + rect.left <= bounds.right + ); +} + +function getJinjaContext( + value: string, + cursor: number, +): { start: number; replaceEnd: number; query: string } | null { + if (cursor < 0) return null; + + const openIdx = value.lastIndexOf("{{", Math.max(0, cursor - 1)); + if (openIdx === -1) return null; + + const closeIdx = value.indexOf("}}", openIdx + 2); + if (closeIdx !== -1 && closeIdx < cursor) return null; + + return { + start: openIdx, + replaceEnd: closeIdx === -1 ? cursor : closeIdx + 2, + query: value.slice(openIdx + 2, cursor).trim(), + }; +} + +function getItemIcon(item: AvailableRefItem) { + if (item.kind === "expression") return FunctionIcon; + if (item.kind === "llm") { + if (item.subtype === "structured") return CodeIcon; + if (item.subtype === "code") return CodeSimpleIcon; + if (item.subtype === "judge") return BalanceScaleIcon; + return PencilEdit02Icon; + } + if (item.subtype === "category") return Tag01Icon; + if (item.subtype === "subcategory") return TagsIcon; + if (item.subtype === "gaussian") return Parabola02Icon; + if (item.subtype === "uniform" || item.subtype === "bernoulli") return EqualSignIcon; + if (item.subtype === "datetime" || item.subtype === "timedelta") return Clock01Icon; + if (item.subtype === "uuid") return FingerPrintIcon; + if (item.subtype === "person" || item.subtype === "person_from_faker") return UserAccountIcon; + return Tag01Icon; +} + +function useJinjaRefAutocomplete( + value: string, + onValueChange: (value: string) => void, + items: AvailableRefItem[], + suppress: boolean, +) { + const fieldRef = useRef(null); + const [focused, setFocused] = useState(false); + const [cursor, setCursor] = useState(null); + const [anchor, setAnchor] = useState(null); + const [inView, setInView] = useState(true); + const ctx = useMemo(() => { + if (!focused || cursor == null) return null; + return getJinjaContext(value, cursor); + }, [focused, cursor, value]); + + const filtered = useMemo(() => { + if (!ctx) return []; + const q = ctx.query.toLowerCase(); + const next = q + ? items.filter((v) => v.ref.toLowerCase().includes(q)) + : items.slice(); + return next.slice(0, MAX_RESULTS); + }, [ctx, items]); + + const open = !suppress && inView && Boolean(ctx && anchor) && items.length > 0; + + const getCaretAnchor = useCallback((el: T, pos: number) => { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + const mirror = document.createElement("div"); + + mirror.style.position = "fixed"; + mirror.style.left = `${rect.left}px`; + mirror.style.top = `${rect.top}px`; + mirror.style.visibility = "hidden"; + mirror.style.pointerEvents = "none"; + mirror.style.whiteSpace = el instanceof HTMLTextAreaElement ? "pre-wrap" : "pre"; + mirror.style.wordBreak = "break-word"; + mirror.style.boxSizing = style.boxSizing; + mirror.style.width = `${rect.width}px`; + mirror.style.height = `${rect.height}px`; + mirror.style.overflow = "auto"; + mirror.style.fontFamily = style.fontFamily; + mirror.style.fontSize = style.fontSize; + mirror.style.fontWeight = style.fontWeight; + mirror.style.letterSpacing = style.letterSpacing; + mirror.style.lineHeight = style.lineHeight; + mirror.style.padding = style.padding; + mirror.style.border = style.border; + mirror.style.textTransform = style.textTransform; + mirror.style.textIndent = style.textIndent; + + const content = el.value ?? ""; + const before = content.slice(0, pos); + const after = content.slice(pos) || "."; + mirror.textContent = before; + + const span = document.createElement("span"); + span.textContent = after; + mirror.appendChild(span); + + document.body.appendChild(mirror); + mirror.scrollTop = (el as unknown as { scrollTop?: number }).scrollTop ?? 0; + mirror.scrollLeft = (el as unknown as { scrollLeft?: number }).scrollLeft ?? 0; + + const spanRect = span.getBoundingClientRect(); + document.body.removeChild(mirror); + + let height = spanRect.height; + if (!Number.isFinite(height) || height <= 0) { + const lhRaw = style.lineHeight; + if (lhRaw && lhRaw !== "normal") { + height = Number.parseFloat(lhRaw); + } else { + height = Number.parseFloat(style.fontSize) * 1.2; + } + } + + return { + x: spanRect.left - rect.left, + y: spanRect.top - rect.top, + height, + }; + }, []); + + const captureCursor = useCallback((el: T | null) => { + if (!el) return; + const pos = el.selectionStart; + if (typeof pos !== "number") { + setCursor(null); + setAnchor(null); + setInView(true); + return; + } + setCursor(pos); + setAnchor(getCaretAnchor(el, pos)); + setInView(isInViewport(el)); + }, [getCaretAnchor]); + + useEffect(() => { + if (suppress) return; + if (!focused) return; + requestAnimationFrame(() => { + captureCursor(fieldRef.current); + }); + }, [captureCursor, focused, suppress]); + + const insertRef = useCallback( + (refName: string) => { + if (!ctx) return; + const replacement = `{{ ${refName} }}`; + const next = + value.slice(0, ctx.start) + replacement + value.slice(ctx.replaceEnd); + onValueChange(next); + + const nextCursor = ctx.start + replacement.length; + requestAnimationFrame(() => { + const el = fieldRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(nextCursor, nextCursor); + captureCursor(el); + }); + }, + [captureCursor, ctx, onValueChange, value], + ); + + const onFocus = useCallback( + (event: FocusEvent) => { + setFocused(true); + captureCursor(event.currentTarget); + }, + [captureCursor], + ); + + const onBlur = useCallback(() => { + setFocused(false); + }, []); + + const onSelect = useCallback( + (event: React.SyntheticEvent) => { + captureCursor(event.currentTarget); + }, + [captureCursor], + ); + + return { + fieldRef, + open, + filtered, + insertRef, + onFocus, + onBlur, + onSelect, + captureCursor, + anchor, + }; +} + +function RefList({ + items, + onPick, +}: { + items: AvailableRefItem[]; + onPick: (value: string) => void; +}): ReactElement { + if (items.length === 0) { + return ( +
+ No matches +
+ ); + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +export function JinjaRefInput({ + value, + onValueChange, + items, + suppress = false, + id, + placeholder, + className, + disabled, +}: { + value: string; + onValueChange: (value: string) => void; + items: AvailableRefItem[]; + suppress?: boolean; + id?: string; + placeholder?: string; + className?: string; + disabled?: boolean; +}): ReactElement { + const { + fieldRef, + open, + filtered, + insertRef, + onFocus, + onBlur, + onSelect, + captureCursor, + anchor, + } = useJinjaRefAutocomplete(value, onValueChange, items, suppress); + + const onChange = useCallback( + (event: ChangeEvent) => { + onValueChange(event.target.value); + captureCursor(event.target); + }, + [captureCursor, onValueChange], + ); + + return ( + +
+ + + + {anchor && ( + + + + )} +
+ event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + > + + +
+ ); +} + +export function JinjaRefTextarea({ + value, + onValueChange, + items, + suppress = false, + id, + placeholder, + className, + disabled, +}: { + value: string; + onValueChange: (value: string) => void; + items: AvailableRefItem[]; + suppress?: boolean; + id?: string; + placeholder?: string; + className?: string; + disabled?: boolean; +}): ReactElement { + const { + fieldRef, + open, + filtered, + insertRef, + onFocus, + onBlur, + onSelect, + captureCursor, + anchor, + } = useJinjaRefAutocomplete(value, onValueChange, items, suppress); + + const onChange = useCallback( + (event: ChangeEvent) => { + onValueChange(event.target.value); + captureCursor(event.target); + }, + [captureCursor, onValueChange], + ); + + return ( + +
+ +