feat: add Jinja reference autocomplete components and enhance graph edges styling
This commit is contained in:
parent
e145a72adb
commit
175fd0459c
16 changed files with 745 additions and 180 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}"}.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue