fix(recipe-studio): support user.* refs validation + toggle user badge details; style user refs/node amber

This commit is contained in:
Shine1i 2026-02-26 14:27:36 +01:00
commit 7ed6ad1e0c
3 changed files with 88 additions and 16 deletions

View file

@ -97,6 +97,8 @@ const NODE_META = {
tone: "bg-orange-50 text-orange-600 border-orange-100",
},
} as const;
const USER_NODE_TONE =
"bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900/60";
const SAMPLER_ICONS: Record<SamplerType, IconType> = {
category: Tag01Icon,
@ -342,6 +344,12 @@ function RecipeGraphNodeBase({
(Boolean(config.prompt.trim()) ||
Boolean(config.system_prompt.trim()) ||
Boolean((config.scores?.length ?? 0) > 0));
const iconTone =
config?.kind === "sampler" &&
(config.sampler_type === "person" ||
config.sampler_type === "person_from_faker")
? USER_NODE_TONE
: meta.tone;
return (
<BaseNode className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm">
@ -362,7 +370,7 @@ function RecipeGraphNodeBase({
<div
className={cn(
"corner-squircle flex size-7 items-center justify-center rounded-md border",
meta.tone,
iconTone,
)}
>
<HugeiconsIcon icon={icon} className="size-3.5" />

View file

@ -1,5 +1,5 @@
import { Badge } from "@/components/ui/badge";
import type { ReactElement } from "react";
import { type ReactElement, useMemo, useState } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import { getAvailableVariableEntries } from "../../utils/variables";
@ -7,11 +7,33 @@ type AvailableVariablesProps = {
configId: string;
};
const USER_EXPANDED_FIELDS = [
"first_name",
"last_name",
"sex",
"city",
"state",
"age",
] as const;
const USER_BADGE_CLASS =
"corner-squircle border-amber-500/25 bg-amber-500/10 font-mono text-[11px] text-amber-700 dark:text-amber-300";
export function AvailableVariables({
configId,
}: AvailableVariablesProps): ReactElement | null {
const [showUserFields, setShowUserFields] = useState(false);
const configs = useRecipeStudioStore((state) => state.configs);
const vars = getAvailableVariableEntries(configs, configId);
const variableNames = useMemo(() => new Set(vars.map((entry) => entry.name)), [vars]);
const hasUserRoot = variableNames.has("user");
const userFieldEntries = useMemo(
() =>
USER_EXPANDED_FIELDS.map((field) => ({
source: "column" as const,
name: `user.${field}`,
})).filter((entry) => !variableNames.has(entry.name)),
[variableNames],
);
if (vars.length === 0) return null;
@ -21,19 +43,48 @@ export function AvailableVariables({
Available references
</p>
<div className="flex flex-wrap gap-1.5">
{vars.map((v) => (
<Badge
key={`${v.source}:${v.name}`}
variant="secondary"
className={
v.source === "seed"
? "corner-squircle border-blue-500/25 bg-blue-500/10 font-mono text-[11px] text-blue-700 dark:text-blue-300"
: "corner-squircle font-mono text-[11px]"
}
>
{`{{ ${v.name} }}`}
</Badge>
))}
{vars.map((v) => {
const className =
v.name === "user" || v.name.startsWith("user.")
? USER_BADGE_CLASS
: v.source === "seed"
? "corner-squircle border-blue-500/25 bg-blue-500/10 font-mono text-[11px] text-blue-700 dark:text-blue-300"
: "corner-squircle font-mono text-[11px]";
if (v.name !== "user") {
return (
<Badge
key={`${v.source}:${v.name}`}
variant="secondary"
className={className}
>
{`{{ ${v.name} }}`}
</Badge>
);
}
return (
<button
key={`${v.source}:${v.name}`}
type="button"
onClick={() => setShowUserFields((prev) => !prev)}
className="cursor-pointer"
aria-expanded={showUserFields}
>
<Badge variant="secondary" className={className}>
{`{{ ${v.name} }}`}
</Badge>
</button>
);
})}
{hasUserRoot && showUserFields &&
userFieldEntries.map((entry) => (
<Badge
key={`user-expanded:${entry.name}`}
variant="secondary"
className={USER_BADGE_CLASS}
>
{`{{ ${entry.name} }}`}
</Badge>
))}
</div>
</div>
);

View file

@ -2,6 +2,19 @@ const JINJA_REF_RE = /{{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}/g;
const JINJA_EXPR_RE = /{{\s*([^{}]+?)\s*}}/g;
const SIMPLE_JINJA_EXPR_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
const PLAIN_JINJA_EXPR_RE = /^[a-zA-Z0-9_.\s-]+$/;
const NESTED_REFERENCE_ROOTS = new Set(["user"]);
function isValidNestedReference(expr: string, validSet: Set<string>): boolean {
if (!expr.includes(".")) {
return false;
}
const parts = expr.split(".").map((part) => part.trim()).filter(Boolean);
if (parts.length < 2) {
return false;
}
const root = parts[0];
return validSet.has(root) && NESTED_REFERENCE_ROOTS.has(root);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@ -38,7 +51,7 @@ export function findInvalidJinjaReferences(
continue;
}
if (SIMPLE_JINJA_EXPR_RE.test(expr)) {
if (!validSet.has(expr)) {
if (!validSet.has(expr) && !isValidNestedReference(expr, validSet)) {
invalid.add(expr);
}
continue;