feat(recipe-studio): add jinja ref validation UI for llm/expression fields
This commit is contained in:
parent
1e3d50f876
commit
aaf62095fe
5 changed files with 123 additions and 6 deletions
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue