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:
parent
de24891de6
commit
d25b922177
5 changed files with 257 additions and 62 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue