feat: normalize handle IDs and enhance scorer options UI

- Added handle normalization functions to standardize handle IDs across connections.
- Expanded UI for scorer options with real-time updates, input fields for values and descriptions, and support for adding/removing options.
- Updated graph node handles and their layout logic for better connection visualization.
- Stripped sensitive fields (e.g., `api_key`) from payloads during export.
This commit is contained in:
Shine1i 2026-02-24 00:29:14 +01:00
commit d25b922177
5 changed files with 257 additions and 62 deletions

View file

@ -35,7 +35,7 @@ import type {
NodeConfig,
SamplerType,
} from "../types";
import { getNodeHandleLayout, NODE_HANDLE_CLASS } from "../utils/handle-layout";
import { NODE_HANDLE_CLASS } from "../utils/handle-layout";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
import { InlineCategoryBadges } from "./inline/inline-category-badges";
import { InlineExpression } from "./inline/inline-expression";
@ -342,13 +342,6 @@ function RecipeGraphNodeBase({
data.kind === "seed";
const showSemanticIn = data.kind === "llm" || data.kind === "model_config";
const showSemanticOut = data.kind === "model_config" || data.kind === "model_provider";
const {
dataInPosition,
dataOutPosition,
semanticInPosition,
semanticOutPosition,
} = getNodeHandleLayout(layoutDirection);
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : [];
@ -434,7 +427,16 @@ function RecipeGraphNodeBase({
id={HANDLE_IDS.dataIn}
title="Data input"
type="target"
position={dataInPosition}
position={Position.Left}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataInTop}
title="Data input"
type="target"
position={Position.Top}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
@ -443,7 +445,16 @@ function RecipeGraphNodeBase({
id={HANDLE_IDS.dataOut}
title="Data output"
type="source"
position={dataOutPosition}
position={Position.Right}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOutBottom}
title="Data output"
type="source"
position={Position.Bottom}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
@ -452,27 +463,49 @@ function RecipeGraphNodeBase({
)}
{showSemanticIn && (
<LabeledHandle
id={HANDLE_IDS.semanticIn}
title="Semantic input"
type="target"
position={semanticInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<>
<LabeledHandle
id={HANDLE_IDS.semanticIn}
title="Semantic input"
type="target"
position={Position.Top}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.semanticInLeft}
title="Semantic input"
type="target"
position={Position.Left}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
</>
)}
{showSemanticOut && (
<LabeledHandle
id={HANDLE_IDS.semanticOut}
title="Semantic output"
type="source"
position={semanticOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<>
<LabeledHandle
id={HANDLE_IDS.semanticOut}
title="Semantic output"
type="source"
position={Position.Bottom}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.semanticOutRight}
title="Semantic output"
type="source"
position={Position.Right}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
</>
)}
</BaseNode>
);

View file

@ -1,4 +1,6 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement } from "react";
import type { LlmConfig, Score } from "../../types";
import { FieldLabel } from "../shared/field-label";
@ -36,6 +38,52 @@ export function LlmScoresTab({
]);
}
function updateScore(index: number, patch: Partial<Score>): void {
updateScores(
scores.map((score, currentIndex) =>
currentIndex === index ? { ...score, ...patch } : score,
),
);
}
function addOption(scoreIndex: number): void {
const score = scores[scoreIndex];
if (!score) {
return;
}
updateScore(scoreIndex, {
options: [...(score.options ?? []), { value: "", description: "" }],
});
}
function removeOption(scoreIndex: number, optionIndex: number): void {
const score = scores[scoreIndex];
if (!score) {
return;
}
updateScore(scoreIndex, {
options: (score.options ?? []).filter(
(_option, currentIndex) => currentIndex !== optionIndex,
),
});
}
function updateOption(
scoreIndex: number,
optionIndex: number,
patch: { value?: string; description?: string },
): void {
const score = scores[scoreIndex];
if (!score) {
return;
}
updateScore(scoreIndex, {
options: (score.options ?? []).map((option, currentIndex) =>
currentIndex === optionIndex ? { ...option, ...patch } : option,
),
});
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
@ -44,36 +92,93 @@ export function LlmScoresTab({
hint="Rubrics used by LLM Judge to score each generated row."
/>
<Button type="button" size="xs" variant="outline" onClick={addScore}>
Add scorer block
Add scorer
</Button>
</div>
{scores.length === 0 && (
<p className="text-xs text-muted-foreground">
Add scorer blocks. Each block spawns on graph and connects to this judge
node.
Add at least one scorer.
</p>
)}
{scores.map((score, index) => (
<div
key={`${config.id}-score-${index}`}
className="flex items-center justify-between rounded-xl corner-squircle border border-border/60 px-3 py-2"
className="space-y-2 rounded-xl corner-squircle border border-border/60 px-3 py-2"
>
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-foreground">
{score.name.trim() || `Scorer ${index + 1}`}
</p>
<p className="text-[11px] text-muted-foreground">
{(score.options ?? []).length} options
</p>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeScore(index)}
>
Remove
</Button>
</div>
<Input
className="nodrag h-8 text-xs"
placeholder="Score name"
value={score.name}
onChange={(event) =>
updateScore(index, { name: event.target.value })
}
/>
<Textarea
className="corner-squircle nodrag min-h-[56px] text-xs"
placeholder="Score description"
value={score.description}
onChange={(event) =>
updateScore(index, { description: event.target.value })
}
/>
<div className="space-y-1">
{(score.options ?? []).map((option, optionIndex) => (
<div
key={`${config.id}-score-${index}-option-${optionIndex}`}
className="grid grid-cols-[74px_1fr_auto] gap-1"
>
<Input
className="nodrag h-7 text-xs"
placeholder="Value"
value={option.value}
onChange={(event) =>
updateOption(index, optionIndex, {
value: event.target.value,
})
}
/>
<Input
className="nodrag h-7 text-xs"
placeholder="Description"
value={option.description}
onChange={(event) =>
updateOption(index, optionIndex, {
description: event.target.value,
})
}
/>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeOption(index, optionIndex)}
>
x
</Button>
</div>
))}
<Button
type="button"
size="xs"
variant="outline"
onClick={() => addOption(index)}
>
Add option
</Button>
</div>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeScore(index)}
>
Remove
</Button>
</div>
))}
</div>

View file

@ -47,6 +47,23 @@ type UseRecipePersistenceResult = {
importRecipe: (value: string) => string | null;
};
function stripApiKeys(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stripApiKeys);
}
if (!value || typeof value !== "object") {
return value;
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (key === "api_key") {
continue;
}
output[key] = stripApiKeys(entry);
}
return output;
}
export function useRecipePersistence({
recipeId,
initialRecipeName,
@ -77,7 +94,6 @@ export function useRecipePersistence({
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: SaveTone = !isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
const payloadErrorMessage = payloadResult.errors[0] ?? "Invalid payload.";
useEffect(() => {
const nextName = normalizeNonEmptyName(initialRecipeName, "Unnamed");
@ -143,12 +159,9 @@ export function useRecipePersistence({
const copyRecipe = useCallback(async (): Promise<void> => {
setCopied(false);
if (payloadResult.errors.length > 0) {
toastError("Copy failed", payloadErrorMessage);
return;
}
try {
const ok = await copyTextToClipboard(JSON.stringify(payloadResult.payload, null, 2));
const safePayload = stripApiKeys(payloadResult.payload);
const ok = await copyTextToClipboard(JSON.stringify(safePayload, null, 2));
if (!ok) {
throw new Error("Clipboard not available.");
}
@ -159,7 +172,7 @@ export function useRecipePersistence({
console.error("Copy failed:", error);
toastError("Copy failed", "Could not copy payload.");
}
}, [payloadErrorMessage, payloadResult.errors.length, payloadResult.payload]);
}, [payloadResult.payload]);
const importRecipe = useCallback(
(value: string): string | null => {

View file

@ -1,6 +1,6 @@
import { type Connection, type Edge, addEdge } from "@xyflow/react";
import type { NodeConfig, SamplerConfig } from "../../types";
import { HANDLE_IDS } from "../handles";
import { HANDLE_IDS, normalizeRecipeConnectionHandles } from "../handles";
import { isSemanticRelation } from "./relations";
import {
isCategoryConfig,
@ -52,16 +52,18 @@ function isModelInfraNode(config: NodeConfig): boolean {
}
function isSemanticLane(connection: Connection): boolean {
const normalized = normalizeRecipeConnectionHandles(connection);
return (
connection.sourceHandle === HANDLE_IDS.semanticOut &&
connection.targetHandle === HANDLE_IDS.semanticIn
normalized.sourceHandle === HANDLE_IDS.semanticOut &&
normalized.targetHandle === HANDLE_IDS.semanticIn
);
}
function isDataLane(connection: Connection): boolean {
const normalized = normalizeRecipeConnectionHandles(connection);
return (
connection.sourceHandle === HANDLE_IDS.dataOut &&
connection.targetHandle === HANDLE_IDS.dataIn
normalized.sourceHandle === HANDLE_IDS.dataOut &&
normalized.targetHandle === HANDLE_IDS.dataIn
);
}
@ -124,6 +126,7 @@ export function isValidRecipeConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
): boolean {
const normalizedConnection = normalizeRecipeConnectionHandles(connection);
if (!(connection.source && connection.target)) {
return false;
}
@ -137,12 +140,12 @@ export function isValidRecipeConnection(
}
const semanticRelation = isSemanticRelation(source, target);
if (semanticRelation) {
return isSemanticLane(connection);
return isSemanticLane(normalizedConnection);
}
if (isModelInfraNode(source) || isModelInfraNode(target)) {
return false;
}
return isDataLane(connection);
return isDataLane(normalizedConnection);
}
export function applyRecipeConnection(
@ -150,11 +153,16 @@ export function applyRecipeConnection(
configs: Record<string, NodeConfig>,
edges: Edge[],
): { edges: Edge[]; configs?: Record<string, NodeConfig> } {
if (!isValidRecipeConnection(connection, configs)) {
const normalizedConnection = normalizeRecipeConnectionHandles(connection);
if (!isValidRecipeConnection(normalizedConnection, configs)) {
return { edges };
}
const source = connection.source ? configs[connection.source] : null;
const target = connection.target ? configs[connection.target] : null;
const source = normalizedConnection.source
? configs[normalizedConnection.source]
: null;
const target = normalizedConnection.target
? configs[normalizedConnection.target]
: null;
if (!(source && target)) {
return { edges };
}
@ -167,7 +175,7 @@ export function applyRecipeConnection(
)
: edges;
const nextEdges = addEdge(
{ ...connection, type: semanticRelation ? "semantic" : "canvas" },
{ ...normalizedConnection, type: semanticRelation ? "semantic" : "canvas" },
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {

View file

@ -1,10 +1,16 @@
import type { Connection } from "@xyflow/react";
export const HANDLE_IDS = {
// data flow lanes
dataIn: "data-in",
dataInTop: "data-in-top",
dataOut: "data-out",
dataOutBottom: "data-out-bottom",
// semantic dependency lanes
semanticIn: "semantic-in",
semanticInLeft: "semantic-in-left",
semanticOut: "semantic-out",
semanticOutRight: "semantic-out-right",
// llm prompt/scorer lanes
llmPromptIn: "llm-prompt-in",
llmSystemIn: "llm-system-in",
@ -16,3 +22,33 @@ export type RecipeHandleId = (typeof HANDLE_IDS)[keyof typeof HANDLE_IDS];
export function getLlmJudgeScoreHandleId(index: number): string {
return `llm-judge-score-in-${index}`;
}
const HANDLE_CANONICAL_MAP: Record<string, string> = {
[HANDLE_IDS.dataIn]: HANDLE_IDS.dataIn,
[HANDLE_IDS.dataInTop]: HANDLE_IDS.dataIn,
[HANDLE_IDS.dataOut]: HANDLE_IDS.dataOut,
[HANDLE_IDS.dataOutBottom]: HANDLE_IDS.dataOut,
[HANDLE_IDS.semanticIn]: HANDLE_IDS.semanticIn,
[HANDLE_IDS.semanticInLeft]: HANDLE_IDS.semanticIn,
[HANDLE_IDS.semanticOut]: HANDLE_IDS.semanticOut,
[HANDLE_IDS.semanticOutRight]: HANDLE_IDS.semanticOut,
};
export function normalizeRecipeHandleId(
handleId: string | null | undefined,
): string | null {
if (!handleId) {
return null;
}
return HANDLE_CANONICAL_MAP[handleId] ?? handleId;
}
export function normalizeRecipeConnectionHandles(
connection: Connection,
): Connection {
return {
...connection,
sourceHandle: normalizeRecipeHandleId(connection.sourceHandle),
targetHandle: normalizeRecipeHandleId(connection.targetHandle),
};
}