feat(recipe-studio): add jinja ref validation UI for llm/expression fields

This commit is contained in:
Shine1i 2026-02-26 13:35:07 +01:00
commit aaf62095fe
5 changed files with 123 additions and 6 deletions

View file

@ -9,6 +9,7 @@ import {
import type { ReactElement } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
import { findInvalidJinjaReferences } from "../../utils/refs";
import { getAvailableVariableEntries } from "../../utils/variables";
import { AvailableReferencesInline } from "../shared/available-references-inline";
import { InlineField } from "./inline-field";
@ -26,6 +27,10 @@ export function InlineExpression({
}: InlineExpressionProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const vars = getAvailableVariableEntries(configs, config.id);
const invalidRefs = findInvalidJinjaReferences(
config.expr,
vars.map((entry) => entry.name),
);
return (
<div className="space-y-3">
@ -52,6 +57,7 @@ export function InlineExpression({
<InlineField label="Expression">
<Input
className="nodrag h-8 w-full text-xs"
aria-invalid={invalidRefs.length > 0}
placeholder="{{ column_name }}"
value={config.expr}
onChange={(event) => onUpdate({ expr: event.target.value })}

View file

@ -13,6 +13,7 @@ import { useRecipeStudioStore } from "../stores/recipe-studio";
import type { LlmConfig, Score, ScoreOption } from "../types";
import { AUX_HANDLE_CLASS } from "../utils/handle-layout";
import { HANDLE_IDS } from "../utils/handles";
import { findInvalidJinjaReferences } from "../utils/refs";
import { getAvailableVariableEntries } from "../utils/variables";
import { AvailableReferencesInline } from "./shared/available-references-inline";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
@ -56,10 +57,12 @@ function updateOptionAt(
);
}
function AuxVariableBadges({ llmId }: { llmId: string }): ReactElement | null {
const configs = useRecipeStudioStore((state) => state.configs);
const vars = getAvailableVariableEntries(configs, llmId);
return <AvailableReferencesInline entries={vars} />;
function AuxVariableBadges({
entries,
}: {
entries: ReturnType<typeof getAvailableVariableEntries>;
}): ReactElement | null {
return <AvailableReferencesInline entries={entries} />;
}
function AuxNodeBase({
@ -67,6 +70,7 @@ function AuxNodeBase({
data,
}: NodeProps<RecipeGraphAuxNodeType>): ReactElement | null {
const config = useRecipeStudioStore((state) => state.configs[data.llmId]);
const configs = useRecipeStudioStore((state) => state.configs);
const updateConfig = useRecipeStudioStore((state) => state.updateConfig);
const updateNodeInternals = useUpdateNodeInternals();
@ -117,6 +121,10 @@ function AuxNodeBase({
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
const variableEntries = getAvailableVariableEntries(configs, data.llmId);
const availableRefs = variableEntries.map((entry) => entry.name);
const hasInvalidRefs =
findInvalidJinjaReferences(value, availableRefs).length > 0;
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
@ -125,6 +133,7 @@ function AuxNodeBase({
<BaseNodeContent className="gap-2 px-3 py-2">
<Textarea
className="corner-squircle nodrag nowheel max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
aria-invalid={hasInvalidRefs}
value={value}
onChange={(event) =>
updateConfig(data.llmId, {
@ -132,7 +141,7 @@ function AuxNodeBase({
} as Partial<LlmConfig>)
}
/>
<AuxVariableBadges llmId={data.llmId} />
<AuxVariableBadges entries={variableEntries} />
</BaseNodeContent>
{sourceHandles}
</BaseNode>

View file

@ -7,7 +7,11 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import { useMemo } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
import { findInvalidJinjaReferences } from "../../utils/refs";
import { getAvailableVariables } from "../../utils/variables";
import { AvailableVariables } from "../shared/available-variables";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -23,8 +27,21 @@ export function ExpressionDialog({
config,
onUpdate,
}: ExpressionDialogProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const dtypeId = `${config.id}-dtype`;
const exprId = `${config.id}-expr`;
const validReferences = useMemo(
() => getAvailableVariables(configs, config.id),
[configs, config.id],
);
const invalidExprRefs = useMemo(
() => findInvalidJinjaReferences(config.expr, validReferences),
[config.expr, validReferences],
);
const invalidExprText = invalidExprRefs
.slice(0, 3)
.map((ref) => `{{ ${ref} }}`)
.join(", ");
const updateField = <K extends keyof ExpressionConfig>(
key: K,
value: ExpressionConfig[K],
@ -71,10 +88,19 @@ export function ExpressionDialog({
<Textarea
id={exprId}
className="corner-squircle nodrag"
aria-invalid={invalidExprRefs.length > 0}
placeholder="{{ category_1 }} - {{ subcategory_1 }}"
value={config.expr}
onChange={(event) => updateField("expr", event.target.value)}
/>
{invalidExprRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidExprText}
{invalidExprRefs.length > 3
? ` +${invalidExprRefs.length - 3} more`
: ""}
</p>
)}
<p className="text-xs text-muted-foreground">
Use Jinja2. Reference columns like {"{{ column_name }}"}.
</p>

View file

@ -14,8 +14,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, type RefObject } from "react";
import { type ReactElement, type RefObject, useMemo } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { LlmConfig } from "../../types";
import { findInvalidJinjaReferences } from "../../utils/refs";
import { getAvailableVariables } from "../../utils/variables";
import { AvailableVariables } from "../shared/available-variables";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -54,6 +57,7 @@ export function LlmGeneralTab({
modelAliasAnchorRef,
onUpdate,
}: LlmGeneralTabProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const modelAliasId = `${config.id}-model-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;
@ -61,6 +65,26 @@ export function LlmGeneralTab({
const systemPromptId = `${config.id}-system-prompt`;
const hasModelConfigs = modelConfigAliases.length > 0;
const hasModelProviders = modelProviderOptions.length > 0;
const validReferences = useMemo(
() => getAvailableVariables(configs, config.id),
[configs, config.id],
);
const invalidPromptRefs = useMemo(
() => findInvalidJinjaReferences(config.prompt, validReferences),
[config.prompt, validReferences],
);
const invalidSystemRefs = useMemo(
() => findInvalidJinjaReferences(config.system_prompt, validReferences),
[config.system_prompt, validReferences],
);
const invalidPromptText = invalidPromptRefs
.slice(0, 3)
.map((ref) => `{{ ${ref} }}`)
.join(", ");
const invalidSystemText = invalidSystemRefs
.slice(0, 3)
.map((ref) => `{{ ${ref} }}`)
.join(", ");
return (
<div className="space-y-4">
@ -148,9 +172,18 @@ export function LlmGeneralTab({
<Textarea
id={promptId}
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidPromptRefs.length > 0}
value={config.prompt}
onChange={(event) => onUpdate({ prompt: event.target.value })}
/>
{invalidPromptRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidPromptText}
{invalidPromptRefs.length > 3
? ` +${invalidPromptRefs.length - 3} more`
: ""}
</p>
)}
</div>
{config.llm_type === "structured" && (
<div className="grid gap-2">
@ -178,9 +211,18 @@ export function LlmGeneralTab({
<Textarea
id={systemPromptId}
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidSystemRefs.length > 0}
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
/>
{invalidSystemRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidSystemText}
{invalidSystemRefs.length > 3
? ` +${invalidSystemRefs.length - 3} more`
: ""}
</p>
)}
</div>
</div>
);

View file

@ -1,4 +1,7 @@
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-]+$/;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@ -17,6 +20,37 @@ export function extractRefs(template: string): string[] {
return Array.from(refs);
}
export function findInvalidJinjaReferences(
template: string,
validReferences: string[],
): string[] {
if (!template) {
return [];
}
const validSet = new Set(
validReferences.map((name) => name.trim()).filter(Boolean),
);
const invalid = new Set<string>();
for (const match of template.matchAll(JINJA_EXPR_RE)) {
const expr = (match[1] ?? "").trim();
if (!expr) {
continue;
}
if (SIMPLE_JINJA_EXPR_RE.test(expr)) {
if (!validSet.has(expr)) {
invalid.add(expr);
}
continue;
}
if (PLAIN_JINJA_EXPR_RE.test(expr)) {
invalid.add(expr);
}
}
return Array.from(invalid);
}
export function replaceRef(
template: string,
from: string,