feat: add Jinja reference autocomplete components and enhance graph edges styling

This commit is contained in:
Shine1i 2026-02-14 16:30:01 +01:00
commit 175fd0459c
16 changed files with 745 additions and 180 deletions

View file

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

View file

@ -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<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
formatter,
color,
nameKey,
labelKey,
}: Partial<RechartsPrimitive.TooltipContentProps<any, any>> &
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<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;

View file

@ -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);

View file

@ -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 (
<div className="flex flex-col items-end gap-2">
<Sheet
open={open}
onOpenChange={(open) => {
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" && (

View file

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

View file

@ -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 (
<div className="space-y-3">
@ -50,11 +52,13 @@ export function InlineExpression({
</Select>
</InlineField>
<InlineField label="Expression">
<Input
<JinjaRefInput
className="nodrag h-8 w-full text-xs"
placeholder="{{ column_name }}"
value={config.expr}
onChange={(event) => onUpdate({ expr: event.target.value })}
items={items}
suppress={flowMoving}
onValueChange={(value) => onUpdate({ expr: value })}
/>
</InlineField>
</div>

View file

@ -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<T extends HTMLInputElement | HTMLTextAreaElement>(
value: string,
onValueChange: (value: string) => void,
items: AvailableRefItem[],
suppress: boolean,
) {
const fieldRef = useRef<T | null>(null);
const [focused, setFocused] = useState(false);
const [cursor, setCursor] = useState<number | null>(null);
const [anchor, setAnchor] = useState<CaretAnchor | null>(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<T>) => {
setFocused(true);
captureCursor(event.currentTarget);
},
[captureCursor],
);
const onBlur = useCallback(() => {
setFocused(false);
}, []);
const onSelect = useCallback(
(event: React.SyntheticEvent<T>) => {
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 (
<div className="px-2 py-2 text-xs text-muted-foreground">
No matches
</div>
);
}
return (
<div className="max-h-64 overflow-auto p-1">
{items.map((item) => (
<button
key={item.ref}
type="button"
className="corner-squircle flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(item.ref)}
>
<span className="corner-squircle flex size-8 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/30">
<HugeiconsIcon icon={getItemIcon(item)} strokeWidth={2} className="size-4" />
</span>
<span className="min-w-0 flex-1 font-mono text-[13px]">
<span className="block truncate">{item.ref}</span>
</span>
<span className="corner-squircle shrink-0 rounded-md bg-muted/40 px-2 py-1 text-[11px] text-muted-foreground">
{item.valueType ?? `${item.kind}:${item.subtype}`}
</span>
</button>
))}
</div>
);
}
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<HTMLInputElement>(value, onValueChange, items, suppress);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onValueChange(event.target.value);
captureCursor(event.target);
},
[captureCursor, onValueChange],
);
return (
<Popover open={open}>
<div className="relative">
<PopoverTrigger asChild={true}>
<Input
ref={fieldRef}
id={id}
disabled={disabled}
className={cn(className)}
placeholder={placeholder}
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
onSelect={onSelect}
/>
</PopoverTrigger>
{anchor && (
<PopoverAnchor asChild={true}>
<span
className="pointer-events-none absolute"
style={{
left: anchor.x,
top: anchor.y + anchor.height,
width: 1,
height: 1,
}}
/>
</PopoverAnchor>
)}
</div>
<PopoverContent
align="start"
side="bottom"
sideOffset={8}
className="corner-squircle nodrag nopan w-[360px] gap-0 rounded-xl p-1"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<RefList items={filtered} onPick={insertRef} />
</PopoverContent>
</Popover>
);
}
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<HTMLTextAreaElement>(value, onValueChange, items, suppress);
const onChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
onValueChange(event.target.value);
captureCursor(event.target);
},
[captureCursor, onValueChange],
);
return (
<Popover open={open}>
<div className="relative">
<PopoverTrigger asChild={true}>
<Textarea
ref={fieldRef}
id={id}
disabled={disabled}
className={cn(className)}
placeholder={placeholder}
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
onSelect={onSelect}
/>
</PopoverTrigger>
{anchor && (
<PopoverAnchor asChild={true}>
<span
className="pointer-events-none absolute"
style={{
left: anchor.x,
top: anchor.y + anchor.height,
width: 1,
height: 1,
}}
/>
</PopoverAnchor>
)}
</div>
<PopoverContent
align="start"
side="bottom"
sideOffset={8}
className="corner-squircle nodrag nopan w-[300px] gap-0 rounded-xl p-1"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<RefList items={filtered} onPick={insertRef} />
</PopoverContent>
</Popover>
);
}

View file

@ -8,14 +8,16 @@ import {
Position,
type Node,
type NodeProps,
useUpdateNodeInternals,
} from "@xyflow/react";
import { memo, type ReactElement } from "react";
import { memo, type ReactElement, useEffect } from "react";
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { useRecipeStudioStore } from "../stores/recipe-studio";
import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
import { HANDLE_IDS } from "../utils/handles";
import { getAvailableVariables } from "../utils/variables";
import { getAvailableRefItems, getAvailableVariables } from "../utils/variables";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
import { JinjaRefTextarea } from "./jinja/jinja-ref-autocomplete";
type PromptField = "prompt" | "system_prompt";
@ -81,10 +83,18 @@ function AuxVariableBadges({ llmId }: { llmId: string }): ReactElement | null {
}
function AuxNodeBase({
id,
data,
}: NodeProps<RecipeGraphAuxNodeType>): ReactElement | null {
const config = useRecipeStudioStore((state) => state.configs[data.llmId]);
const configs = useRecipeStudioStore((state) => state.configs);
const flowMoving = useRecipeStudioStore((state) => state.flowMoving);
const updateConfig = useRecipeStudioStore((state) => state.updateConfig);
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
updateNodeInternals(id);
}, [id, updateNodeInternals]);
if (!(config && config.kind === "llm")) {
return null;
@ -95,6 +105,7 @@ function AuxNodeBase({
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
const items = getAvailableRefItems(configs, data.llmId);
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<NodeResizer
@ -113,13 +124,13 @@ function AuxNodeBase({
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<Textarea
<JinjaRefTextarea
className="corner-squircle nodrag max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
value={value}
onChange={(event) =>
updateConfig(data.llmId, {
[data.field]: event.target.value,
} as Partial<LlmConfig>)
items={items}
suppress={flowMoving}
onValueChange={(next) =>
updateConfig(data.llmId, { [data.field]: next } as Partial<LlmConfig>)
}
/>
<AuxVariableBadges llmId={data.llmId} />
@ -128,6 +139,8 @@ function AuxNodeBase({
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className="!size-2 !border-border !bg-background"
/>
</BaseNode>
@ -249,6 +262,8 @@ function AuxNodeBase({
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className="!size-2 !border-border !bg-background"
/>
</BaseNode>

View file

@ -1,19 +1,15 @@
import { useMemo, type ReactElement } from "react";
import type { ReactElement } from "react";
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
getSmoothStepPath,
getStraightPath,
Position,
useStore,
type Edge,
type EdgeProps,
type Node,
} from "@xyflow/react";
export type DataEdge<T extends Node = Node> = Edge<{
key?: keyof T["data"];
export type DataEdge = Edge<{
path?: "auto" | "bezier" | "smoothstep" | "step" | "straight";
}>;
@ -21,7 +17,6 @@ export function DataEdge({
data = { path: "auto" },
id,
markerEnd,
source,
sourcePosition,
sourceX,
sourceY,
@ -30,7 +25,6 @@ export function DataEdge({
targetX,
targetY,
}: EdgeProps<DataEdge>): ReactElement {
const nodeData = useStore((state) => state.nodeLookup.get(source)?.data);
const resolvedPathType = resolvePathType({
type: data.path ?? "auto",
sourceX,
@ -40,7 +34,7 @@ export function DataEdge({
sourcePosition,
targetPosition,
});
const [edgePath, labelX, labelY] = getPath({
const [edgePath] = getPath({
type: resolvedPathType,
sourceX,
sourceY,
@ -50,35 +44,8 @@ export function DataEdge({
targetPosition,
});
const label = useMemo(() => {
if (data.key && nodeData) {
const value = nodeData[data.key];
if (typeof value === "string" || typeof value === "number") {
return value;
}
if (typeof value === "object") {
return JSON.stringify(value);
}
}
return "";
}, [data, nodeData]);
const transform = `translate(${labelX}px,${labelY}px) translate(-50%, -50%)`;
return (
<>
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
{data.key && (
<EdgeLabelRenderer>
<div
className="absolute rounded border bg-background px-1 text-foreground"
style={{ transform }}
>
<pre className="text-xs">{label}</pre>
</div>
</EdgeLabelRenderer>
)}
</>
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
);
}

View file

@ -5,9 +5,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import { getAvailableRefItems } from "../../utils/variables";
import { JinjaRefTextarea } from "../../components/jinja/jinja-ref-autocomplete";
import { AvailableVariables } from "../shared/available-variables";
import { NameField } from "../shared/name-field";
@ -22,6 +24,8 @@ export function ExpressionDialog({
config,
onUpdate,
}: ExpressionDialogProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const items = getAvailableRefItems(configs, config.id);
const dtypeId = `${config.id}-dtype`;
const exprId = `${config.id}-expr`;
const updateField = <K extends keyof ExpressionConfig>(
@ -69,12 +73,13 @@ export function ExpressionDialog({
>
Expression (Jinja2)
</label>
<Textarea
<JinjaRefTextarea
id={exprId}
className="corner-squircle nodrag"
placeholder="{{ category_1 }} - {{ subcategory_1 }}"
value={config.expr}
onChange={(event) => updateField("expr", event.target.value)}
items={items}
onValueChange={(value) => updateField("expr", value)}
/>
<p className="text-xs text-muted-foreground">
Use Jinja2. Reference columns like {"{{ column_name }}"}.

View file

@ -16,6 +16,9 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, type RefObject } from "react";
import type { LlmConfig } from "../../types";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import { getAvailableRefItems } from "../../utils/variables";
import { JinjaRefTextarea } from "../../components/jinja/jinja-ref-autocomplete";
import { AvailableVariables } from "../shared/available-variables";
import { NameField } from "../shared/name-field";
@ -51,6 +54,8 @@ export function LlmGeneralTab({
modelAliasAnchorRef,
onUpdate,
}: LlmGeneralTabProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const items = getAvailableRefItems(configs, config.id);
const modelAliasId = `${config.id}-model-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;
@ -134,11 +139,12 @@ export function LlmGeneralTab({
>
Prompt
</label>
<Textarea
<JinjaRefTextarea
id={promptId}
className="corner-squircle nodrag"
value={config.prompt}
onChange={(event) => onUpdate({ prompt: event.target.value })}
items={items}
onValueChange={(value) => onUpdate({ prompt: value })}
/>
</div>
{config.llm_type === "structured" && (
@ -166,11 +172,12 @@ export function LlmGeneralTab({
>
System prompt (optional)
</label>
<Textarea
<JinjaRefTextarea
id={systemPromptId}
className="corner-squircle nodrag"
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
items={items}
onValueChange={(value) => onUpdate({ system_prompt: value })}
/>
</div>
</div>

View file

@ -109,6 +109,7 @@ export function RecipeStudioPage({
setAuxNodeSize,
syncAuxNodePositions,
syncAuxNodeSizes,
setFlowMoving,
} = useRecipeStudioStore(
useShallow((state) => ({
nodes: state.nodes,
@ -143,6 +144,7 @@ export function RecipeStudioPage({
setAuxNodeSize: state.setAuxNodeSize,
syncAuxNodePositions: state.syncAuxNodePositions,
syncAuxNodeSizes: state.syncAuxNodeSizes,
setFlowMoving: state.setFlowMoving,
})),
);
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
@ -197,8 +199,9 @@ export function RecipeStudioPage({
if (!("id" in change) || !change.id.startsWith("aux-")) {
continue;
}
if (change.type === "position" && change.position) {
setAuxNodePosition(change.id, change.position);
if (change.type === "position") {
const nextPosition = change.position ?? change.positionAbsolute;
if (nextPosition) setAuxNodePosition(change.id, nextPosition);
continue;
}
if (
@ -339,14 +342,17 @@ export function RecipeStudioPage({
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { key: "name", path: "auto" },
style: { strokeWidth: 1.5, stroke: "var(--border)" },
data: { path: "auto" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
isValidConnection={isValidConnection}
onMoveStart={() => setFlowMoving(true)}
onMoveEnd={() => setFlowMoving(false)}
onNodeDragStart={() => setFlowMoving(true)}
onNodeDragStop={() => setFlowMoving(false)}
nodesDraggable={interactive}
nodesConnectable={interactive}
elementsSelectable={interactive}

View file

@ -46,12 +46,14 @@ type RecipeStudioState = {
auxNodeSizes: Record<string, { width: number; height: number }>;
configs: Record<string, NodeConfig>;
processors: RecipeProcessorConfig[];
flowMoving: boolean;
sheetView: SheetView;
activeConfigId: string | null;
dialogOpen: boolean;
layoutDirection: LayoutDirection;
nextId: number;
nextY: number;
setFlowMoving: (moving: boolean) => void;
setSheetView: (view: SheetView) => void;
setProcessors: (processors: RecipeProcessorConfig[]) => void;
setDialogOpen: (open: boolean) => void;
@ -105,12 +107,14 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
auxNodeSizes: {},
configs: {},
processors: [],
flowMoving: false,
sheetView: "root",
activeConfigId: null,
dialogOpen: false,
layoutDirection: "LR",
nextId: 3,
nextY: 280,
setFlowMoving: (moving) => set({ flowMoving: moving }),
setSheetView: (view) => set({ sheetView: view }),
setProcessors: (processors) => set({ processors }),
setDialogOpen: (open) => set({ dialogOpen: open }),
@ -122,6 +126,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
auxNodeSizes: {},
configs: {},
processors: [],
flowMoving: false,
sheetView: "root",
activeConfigId: null,
dialogOpen: false,
@ -183,6 +188,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
auxNodeSizes: {},
activeConfigId: null,
dialogOpen: false,
flowMoving: false,
sheetView: "root",
})),
setAuxNodePosition: (id, position) =>

View file

@ -20,6 +20,35 @@ export type DisplayGraph = {
auxDefaults: Record<string, XYPosition>;
};
function normalizeEdge(edge: Edge, configs: Record<string, NodeConfig>): Edge {
const baseStyle = { stroke: "var(--foreground)", strokeWidth: 2 };
const isAux = edge.source.startsWith("aux-") || edge.target.startsWith("aux-");
if (isAux) {
return {
...edge,
type: "canvas",
style: { ...baseStyle, ...(edge.style ?? {}) },
};
}
const source = configs[edge.source];
const target = configs[edge.target];
const semantic =
Boolean(source && target) &&
((source.kind === "model_provider" && target?.kind === "model_config") ||
(source.kind === "model_config" && target?.kind === "llm"));
const handles = semantic
? { sourceHandle: HANDLE_IDS.semanticOut, targetHandle: HANDLE_IDS.semanticIn }
: { sourceHandle: HANDLE_IDS.dataOut, targetHandle: HANDLE_IDS.dataIn };
return {
...edge,
type: semantic ? "semantic" : "canvas",
...handles,
style: { ...baseStyle, ...(edge.style ?? {}) },
};
}
type AuxNodeItem = {
key: string;
targetHandle: string;
@ -193,6 +222,8 @@ export function deriveDisplayGraph({
type: "aux",
data: entry.item.data,
position,
width: entry.width,
height: entry.height,
style: {
width: entry.width,
height: entry.height,
@ -200,7 +231,7 @@ export function deriveDisplayGraph({
draggable: true,
selectable: true,
focusable: true,
connectable: false,
connectable: true,
});
auxEdges.push({
@ -213,7 +244,6 @@ export function deriveDisplayGraph({
data: { path: "auto" },
selectable: false,
focusable: false,
style: { strokeWidth: 1.5, stroke: "var(--border)" },
});
}
continue;
@ -244,6 +274,8 @@ export function deriveDisplayGraph({
type: "aux",
data: entry.item.data,
position,
width: entry.width,
height: entry.height,
style: {
width: entry.width,
height: entry.height,
@ -251,7 +283,7 @@ export function deriveDisplayGraph({
draggable: true,
selectable: true,
focusable: true,
connectable: false,
connectable: true,
});
auxEdges.push({
@ -264,14 +296,13 @@ export function deriveDisplayGraph({
data: { path: "auto" },
selectable: false,
focusable: false,
style: { strokeWidth: 1.5, stroke: "var(--border)" },
});
}
}
return {
nodes: [...displayNodes, ...auxNodes],
edges: [...edges, ...auxEdges],
edges: [...edges, ...auxEdges].map((edge) => normalizeEdge(edge, configs)),
auxNodeIds,
auxDefaults,
};

View file

@ -18,7 +18,7 @@ export function buildEdges(
const edges: Edge[] = [];
const seen = new Set<string>();
const configByName = new Map(configs.map((config) => [config.name, config]));
const addEdgeByName = (from: string, to: string, type?: string) => {
const addEdgeByName = (from: string, to: string) => {
const sourceId = nameToId.get(from);
const targetId = nameToId.get(to);
if (!(sourceId && targetId)) {
@ -31,10 +31,8 @@ export function buildEdges(
seen.add(key);
const source = configByName.get(from);
const target = configByName.get(to);
const normalizedType =
source && target && isSemanticConnection(source, target)
? "semantic"
: (type ?? "canvas");
const isSemantic = Boolean(source && target && isSemanticConnection(source, target));
const normalizedType = isSemantic ? "semantic" : "canvas";
const handles =
normalizedType === "semantic"
? {
@ -56,7 +54,7 @@ export function buildEdges(
if (uiEdges && uiEdges.length > 0) {
for (const edge of uiEdges) {
addEdgeByName(edge.from, edge.to, edge.type);
addEdgeByName(edge.from, edge.to);
}
return edges;
}
@ -80,20 +78,20 @@ export function buildEdges(
config.sampler_type === "subcategory" &&
config.subcategory_parent
) {
addEdgeByName(config.subcategory_parent, config.name, "canvas");
addEdgeByName(config.subcategory_parent, config.name);
}
if (config.kind === "model_config" && config.provider) {
addEdgeByName(config.provider, config.name, "semantic");
addEdgeByName(config.provider, config.name);
}
if (
config.kind === "sampler" &&
config.sampler_type === "timedelta" &&
config.reference_column_name
) {
addEdgeByName(config.reference_column_name, config.name, "canvas");
addEdgeByName(config.reference_column_name, config.name);
}
if (config.kind === "llm" && config.model_alias) {
addEdgeByName(config.model_alias, config.name, "semantic");
addEdgeByName(config.model_alias, config.name);
}
}

View file

@ -1,26 +1,76 @@
import type { NodeConfig } from "../types";
export function getAvailableVariables(
configs: Record<string, NodeConfig>,
currentId: string,
): string[] {
const vars: string[] = [];
for (const config of Object.values(configs)) {
if (config.id === currentId) continue;
if (config.kind === "model_provider" || config.kind === "model_config") continue;
vars.push(config.name);
if (config.kind === "llm" && config.llm_type === "structured" && config.output_format) {
try {
const schema = JSON.parse(config.output_format);
if (schema.properties) {
for (const key of Object.keys(schema.properties)) {
vars.push(`${config.name}.${key}`);
}
}
} catch {
/* skip invalid JSON */
}
}
}
return vars;
}
import type { NodeConfig } from "../types";
export type AvailableRefItem = {
ref: string;
kind: Exclude<NodeConfig["kind"], "model_provider" | "model_config">;
subtype: string;
valueType?: string;
};
function getStructuredRefs(
llmName: string,
outputFormat: string,
): Array<{ ref: string; valueType?: string }> {
try {
const schema = JSON.parse(outputFormat);
if (!(schema?.properties && typeof schema.properties === "object")) {
return [];
}
return Object.keys(schema.properties).map((key) => {
const prop = schema.properties[key];
const valueType =
prop && typeof prop === "object" && typeof prop.type === "string"
? prop.type
: undefined;
return { ref: `${llmName}.${key}`, valueType };
});
} catch {
return [];
}
}
export function getAvailableRefItems(
configs: Record<string, NodeConfig>,
currentId: string,
): AvailableRefItem[] {
const items: AvailableRefItem[] = [];
for (const config of Object.values(configs)) {
if (config.id === currentId) continue;
if (config.kind === "model_provider" || config.kind === "model_config") continue;
if (config.kind === "sampler") {
items.push({ ref: config.name, kind: "sampler", subtype: config.sampler_type });
continue;
}
if (config.kind === "expression") {
items.push({ ref: config.name, kind: "expression", subtype: config.dtype });
continue;
}
if (config.kind === "llm") {
items.push({ ref: config.name, kind: "llm", subtype: config.llm_type });
if (config.llm_type === "structured" && config.output_format) {
for (const ref of getStructuredRefs(config.name, config.output_format)) {
items.push({
ref: ref.ref,
kind: "llm",
subtype: config.llm_type,
valueType: ref.valueType,
});
}
}
}
}
return items;
}
export function getAvailableVariables(
configs: Record<string, NodeConfig>,
currentId: string,
): string[] {
return getAvailableRefItems(configs, currentId).map((item) => item.ref);
}