add variable handling components and refactor inputs across dialogs

- Introduce `AvailableVariables` for displaying variables linked to configs.
- Implement `ChipInput` for dynamic value management in category and subcategory dialogs.
- Add `AuxVariableBadges` to aux nodes for displaying variable references.
- Update inline components with comboboxes for better user experience.
- Replace badges and manual inputs with streamlined reusable components.
This commit is contained in:
shine1i 2026-02-09 19:48:42 +01:00
commit 79614f0066
18 changed files with 454 additions and 301 deletions

View file

@ -228,6 +228,7 @@ Current safe flow:
- dialog passes options -> registry -> block dialogs (`LlmDialog`, `ModelConfigDialog`, `TimedeltaDialog`)
No dialog component should import store directly.
Exception: read-only derived data (e.g. `available-variables.tsx` reads `configs` for variable list).
## 9) Add New Block (exact flow)

View file

@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@ -13,6 +14,7 @@ import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { useCanvasLabStore } from "../stores/canvas-lab";
import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
import { HANDLE_IDS } from "../utils/handles";
import { getAvailableVariables } from "../utils/variables";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
type PromptField = "prompt" | "system_prompt";
@ -56,6 +58,28 @@ function updateOptionAt(
);
}
function AuxVariableBadges({ llmId }: { llmId: string }): ReactElement | null {
const configs = useCanvasLabStore((state) => state.configs);
const vars = getAvailableVariables(configs, llmId);
if (vars.length === 0) return null;
return (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">Available references</p>
<div className="flex flex-wrap gap-1">
{vars.map((v) => (
<Badge
key={v}
variant="secondary"
className="corner-squircle h-4 px-1.5 font-mono text-[10px]"
>
{v}
</Badge>
))}
</div>
</div>
);
}
function AuxNodeBase({
data,
}: NodeProps<CanvasAuxNodeType>): ReactElement | null {
@ -88,9 +112,9 @@ function AuxNodeBase({
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent className="px-3 py-2">
<BaseNodeContent className="gap-2 px-3 py-2">
<Textarea
className="nodrag max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
className="corner-squircle nodrag max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
value={value}
onChange={(event) =>
updateConfig(data.llmId, {
@ -98,6 +122,7 @@ function AuxNodeBase({
} as Partial<LlmConfig>)
}
/>
<AuxVariableBadges llmId={data.llmId} />
</BaseNodeContent>
<Handle
id={HANDLE_IDS.llmInputOut}
@ -178,7 +203,7 @@ function AuxNodeBase({
onChange={(event) => updateScore({ name: event.target.value })}
/>
<Textarea
className="nodrag max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
className="corner-squircle nodrag max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
placeholder="Score description"
value={score.description}
onChange={(event) => updateScore({ description: event.target.value })}

View file

@ -35,6 +35,7 @@ import type {
SamplerType,
} from "../types";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
import { InlineCategoryBadges } from "./inline/inline-category-badges";
import { InlineExpression } from "./inline/inline-expression";
import { InlineLlm } from "./inline/inline-llm";
import { InlineModel } from "./inline/inline-model";
@ -165,48 +166,33 @@ function getConfigSummary(config: NodeConfig | undefined): string {
return "Open details for config";
}
function renderInlineEditor(
function renderNodeBody(
config: NodeConfig | undefined,
summary: string,
updateConfig: (id: string, patch: Partial<NodeConfig>) => void,
): ReactElement | null {
if (!config || !isInlineConfig(config)) {
return null;
): ReactElement {
if (config && isInlineConfig(config)) {
const onUpdate = (patch: Partial<NodeConfig>) => updateConfig(config.id, patch);
if (config.kind === "sampler") {
return <InlineSampler config={config} onUpdate={onUpdate} />;
}
if (config.kind === "model_provider" || config.kind === "model_config") {
return <InlineModel config={config} onUpdate={onUpdate} />;
}
if (config.kind === "llm") {
return <InlineLlm config={config} onUpdate={onUpdate} />;
}
if (config.kind === "expression") {
return <InlineExpression config={config} onUpdate={onUpdate} />;
}
}
if (config.kind === "sampler") {
return (
<InlineSampler
config={config}
onUpdate={(patch) => updateConfig(config.id, patch)}
/>
);
if (config?.kind === "sampler" && config.sampler_type === "category") {
return <InlineCategoryBadges values={config.values ?? []} />;
}
if (config.kind === "model_provider" || config.kind === "model_config") {
return (
<InlineModel
config={config}
onUpdate={(patch) => updateConfig(config.id, patch)}
/>
);
}
if (config.kind === "llm") {
return (
<InlineLlm config={config} onUpdate={(patch) => updateConfig(config.id, patch)} />
);
}
if (config.kind === "expression") {
return (
<InlineExpression
config={config}
onUpdate={(patch) => updateConfig(config.id, patch)}
/>
);
}
return null;
return <p className="text-xs text-muted-foreground">{summary}</p>;
}
type LlmInputHandleItem = {
@ -316,8 +302,8 @@ function CanvasNodeBase({
const semanticInPosition = isTopBottom ? Position.Left : Position.Top;
const semanticOutPosition = isTopBottom ? Position.Right : Position.Bottom;
const inlineEditor = renderInlineEditor(config, updateConfig);
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
const llmInputHandles = getLlmInputHandleItems(config);
return (
@ -370,11 +356,7 @@ function CanvasNodeBase({
<BaseNodeContent className="gap-2 px-3 py-2">
<LlmInputHandles items={llmInputHandles} isTopBottom={isTopBottom} />
{inlineEditor ? (
inlineEditor
) : (
<p className="text-xs text-muted-foreground">{summary}</p>
)}
{nodeBody}
</BaseNodeContent>
{showDataHandles && (

View file

@ -0,0 +1,67 @@
import { Button } from "@/components/ui/button";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type KeyboardEvent, type ReactElement, useState } from "react";
type ChipInputProps = {
values: string[];
onAdd: (value: string) => void;
onRemove: (index: number) => void;
placeholder?: string;
};
export function ChipInput({
values,
onAdd,
onRemove,
placeholder = "Type and press Enter",
}: ChipInputProps): ReactElement {
const [draft, setDraft] = useState("");
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
const trimmed = draft.trim();
if (trimmed) {
onAdd(trimmed);
setDraft("");
}
}
if (event.key === "Backspace" && !draft && values.length > 0) {
onRemove(values.length - 1);
}
};
return (
<div className="bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-4xl border bg-clip-padding px-1.5 py-1.5 text-sm transition-colors focus-within:ring-[3px]">
{values.map((value, index) => (
<span
key={`${value}-${index}`}
className="bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-4xl pr-0 pl-2 text-xs font-medium whitespace-nowrap"
>
{value}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 opacity-50 hover:opacity-100"
onClick={() => onRemove(index)}
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="pointer-events-none"
/>
</Button>
</span>
))}
<input
className="nodrag min-w-16 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder={values.length === 0 ? placeholder : ""}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
);
}

View file

@ -0,0 +1,88 @@
import { Badge } from "@/components/ui/badge";
import { type ReactElement, useLayoutEffect, useRef, useState } from "react";
type InlineCategoryBadgesProps = {
values: string[];
};
export function InlineCategoryBadges({
values,
}: InlineCategoryBadgesProps): ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(values.length);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const badges = Array.from(container.children) as HTMLElement[];
if (badges.length === 0) {
setVisibleCount(0);
return;
}
const containerWidth = container.clientWidth;
// Reserve space for the "+N" badge (~36px)
const overflowBadgeWidth = 36;
let count = 0;
let usedWidth = 0;
for (const badge of badges) {
const badgeWidth = badge.scrollWidth + 4; // 4px for gap
if (usedWidth + badgeWidth > containerWidth - overflowBadgeWidth && count < badges.length - 1) {
break;
}
if (usedWidth + badgeWidth > containerWidth) {
break;
}
usedWidth += badgeWidth;
count++;
}
setVisibleCount(count || 1);
}, [values]);
if (values.length === 0) {
return <p className="text-xs text-muted-foreground">No values</p>;
}
const overflow = values.length - visibleCount;
return (
<div className="relative">
{/* Hidden measurer */}
<div
ref={containerRef}
className="pointer-events-none invisible absolute inset-x-0 top-0 flex flex-nowrap gap-1"
aria-hidden
>
{values.map((v, i) => (
<Badge
key={`m-${v}-${i}`}
variant="secondary"
className="corner-squircle h-4 shrink-0 px-1.5 text-[10px]"
>
{v}
</Badge>
))}
</div>
{/* Visible badges */}
<div className="flex flex-wrap gap-1">
{values.slice(0, visibleCount).map((v, i) => (
<Badge
key={`${v}-${i}`}
variant="secondary"
className="corner-squircle h-4 px-1.5 text-[10px]"
>
{v}
</Badge>
))}
{overflow > 0 && (
<Badge variant="outline" className="corner-squircle h-4 px-1.5 text-[10px]">
+{overflow}
</Badge>
)}
</div>
</div>
);
}

View file

@ -1,4 +1,11 @@
import { Input } from "@/components/ui/input";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Select,
SelectContent,
@ -6,7 +13,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ReactElement } from "react";
import { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
import { useCanvasLabStore } from "../../stores/canvas-lab";
import type { LlmConfig } from "../../types";
import { InlineField } from "./inline-field";
@ -36,21 +44,64 @@ const CODE_LANG_OPTIONS = [
export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
const isCode = config.llm_type === "code";
const configs = useCanvasLabStore((state) => state.configs);
const modelConfigAliases = useMemo(
() =>
Object.values(configs)
.filter((c) => c.kind === "model_config")
.map((c) => c.name),
[configs],
);
const [aliasInput, setAliasInput] = useState(config.model_alias);
const anchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setAliasInput(config.model_alias);
}, [config.model_alias]);
return (
<div className="space-y-3">
<InlineField label="Model alias">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="Model alias"
value={config.model_alias}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: event.target.value,
})
}
/>
<div ref={anchorRef}>
<Combobox
items={modelConfigAliases}
filteredItems={modelConfigAliases}
filter={null}
value={config.model_alias || null}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: value ?? "",
})
}
onInputValueChange={setAliasInput}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
className="nodrag h-8 w-full text-xs"
placeholder="Model alias"
onBlur={() => {
if (aliasInput !== config.model_alias) {
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: aliasInput,
});
}
}}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxEmpty>No model configs found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</InlineField>
{isCode && (
<InlineField label="Code language">
@ -77,7 +128,7 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
</InlineField>
)}
<p className="text-[11px] text-muted-foreground">
Prompt/System are edited in dialog or linked input nodes.
Prompt/system edited on aux nodes.
</p>
</div>
);

View file

@ -37,7 +37,7 @@ export function ConfigDialog({
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="sm:max-w-2xl shadow-border"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl shadow-border"
>
<DialogShell />
{!config && (
@ -51,7 +51,7 @@ export function ConfigDialog({
{(config.kind === "sampler" ||
config.kind === "llm" ||
config.kind === "expression") && (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-3 py-2">
<div className="flex items-center corner-squircle justify-between gap-3 rounded-2xl border border-border/60 px-3 py-2">
<div>
<p className="text-sm font-semibold">Drop from final dataset</p>
<p className="text-xs text-muted-foreground">

View file

@ -69,7 +69,7 @@ export function ExpressionDialog({
</label>
<Textarea
id={exprId}
className="nodrag"
className="corner-squircle nodrag"
placeholder="{{ category_1 }} - {{ subcategory_1 }}"
value={config.expr}
onChange={(event) => updateField("expr", event.target.value)}

View file

@ -49,7 +49,7 @@ export function ImportDialog({
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="max-h-[85vh] overflow-auto sm:max-w-2xl"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl"
>
<DialogHeader>
<DialogTitle>Import recipe</DialogTitle>
@ -63,7 +63,7 @@ export function ImportDialog({
</label>
<Textarea
id={payloadId}
className="nodrag min-h-[220px]"
className="corner-squircle nodrag min-h-[220px]"
placeholder='{"recipe": { "columns": [] }}'
value={value}
onChange={(event) => setValue(event.target.value)}

View file

@ -0,0 +1,36 @@
import { Badge } from "@/components/ui/badge";
import type { ReactElement } from "react";
import { useCanvasLabStore } from "../../stores/canvas-lab";
import { getAvailableVariables } from "../../utils/variables";
type AvailableVariablesProps = {
configId: string;
};
export function AvailableVariables({
configId,
}: AvailableVariablesProps): ReactElement | null {
const configs = useCanvasLabStore((state) => state.configs);
const vars = getAvailableVariables(configs, configId);
if (vars.length === 0) return null;
return (
<div className="corner-squircle rounded-2xl border border-border/60 px-3 py-2">
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">
Available variables
</p>
<div className="flex flex-wrap gap-1.5">
{vars.map((v) => (
<Badge
key={v}
variant="secondary"
className="corner-squircle font-mono text-[11px]"
>
{`{{ ${v} }}`}
</Badge>
))}
</div>
</div>
);
}

View file

@ -15,8 +15,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, useRef } from "react";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { LlmConfig, Score } from "../../types";
import { AvailableVariables } from "./available-variables";
import { NameField } from "../shared/name-field";
const CODE_LANG_OPTIONS = [
@ -55,6 +56,10 @@ export function LlmDialog({
const outputFormatId = `${config.id}-output-format`;
const systemPromptId = `${config.id}-system-prompt`;
const modelAliasAnchorRef = useRef<HTMLDivElement>(null);
const [aliasInput, setAliasInput] = useState(config.model_alias);
useEffect(() => {
setAliasInput(config.model_alias);
}, [config.model_alias]);
const scores = config.scores ?? [];
const updateField = <K extends keyof LlmConfig>(
key: K,
@ -81,6 +86,7 @@ export function LlmDialog({
};
return (
<div className="space-y-4">
<AvailableVariables configId={config.id} />
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
@ -99,7 +105,7 @@ export function LlmDialog({
filter={null}
value={config.model_alias || null}
onValueChange={(value) => updateField("model_alias", value ?? "")}
onInputValueChange={(value) => updateField("model_alias", value)}
onInputValueChange={setAliasInput}
itemToStringValue={(value) => value}
autoHighlight={true}
>
@ -107,6 +113,11 @@ export function LlmDialog({
id={modelAliasId}
className="nodrag w-full"
placeholder="Pick model alias or type"
onBlur={() => {
if (aliasInput !== config.model_alias) {
updateField("model_alias", aliasInput);
}
}}
/>
<ComboboxContent anchor={modelAliasAnchorRef}>
<ComboboxEmpty>No model configs found</ComboboxEmpty>
@ -158,7 +169,7 @@ export function LlmDialog({
</label>
<Textarea
id={promptId}
className="nodrag"
className="corner-squircle nodrag"
value={config.prompt}
onChange={(event) => updateField("prompt", event.target.value)}
/>
@ -179,7 +190,7 @@ export function LlmDialog({
</p>
)}
{scores.map((score, index) => (
<div key={`${config.id}-score-${index}`} className="flex items-center justify-between rounded-xl border border-border/60 px-3 py-2">
<div key={`${config.id}-score-${index}`} className="flex items-center justify-between rounded-xl corner-squircle border border-border/60 px-3 py-2">
<div>
<p className="text-xs font-semibold text-foreground">
{score.name.trim() || `Scorer ${index + 1}`}
@ -205,7 +216,7 @@ export function LlmDialog({
</label>
<Textarea
id={outputFormatId}
className="nodrag"
className="corner-squircle nodrag"
value={config.output_format ?? ""}
onChange={(event) =>
updateField("output_format", event.target.value)
@ -225,7 +236,7 @@ export function LlmDialog({
</label>
<Textarea
id={systemPromptId}
className="nodrag"
className="corner-squircle nodrag"
value={config.system_prompt}
onChange={(event) => updateField("system_prompt", event.target.value)}
/>

View file

@ -8,7 +8,7 @@ import {
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { type ReactElement, useRef } from "react";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { ModelConfig } from "../../types";
import { NameField } from "../shared/name-field";
@ -29,6 +29,10 @@ export function ModelConfigDialog({
const topPId = `${config.id}-top-p`;
const maxTokensId = `${config.id}-max-tokens`;
const providerAnchorRef = useRef<HTMLDivElement>(null);
const [providerInput, setProviderInput] = useState(config.provider);
useEffect(() => {
setProviderInput(config.provider);
}, [config.provider]);
const updateField = <K extends keyof ModelConfig>(
key: K,
value: ModelConfig[K],
@ -71,7 +75,7 @@ export function ModelConfigDialog({
filter={null}
value={config.provider || null}
onValueChange={(value) => updateField("provider", value ?? "")}
onInputValueChange={(value) => updateField("provider", value)}
onInputValueChange={setProviderInput}
itemToStringValue={(value) => value}
autoHighlight={true}
>
@ -79,6 +83,11 @@ export function ModelConfigDialog({
id={providerId}
className="nodrag w-full"
placeholder="Pick provider or type name"
onBlur={() => {
if (providerInput !== config.provider) {
updateField("provider", providerInput);
}
}}
/>
<ComboboxContent anchor={providerAnchorRef}>
<ComboboxEmpty>No providers found</ComboboxEmpty>

View file

@ -102,7 +102,7 @@ export function ModelProviderDialog({
</label>
<Textarea
id={extraHeadersId}
className="nodrag"
className="corner-squircle nodrag"
placeholder='{"X-Header": "value"}'
value={config.extra_headers ?? ""}
onChange={(event) =>
@ -119,7 +119,7 @@ export function ModelProviderDialog({
</label>
<Textarea
id={extraBodyId}
className="nodrag"
className="corner-squircle nodrag"
placeholder='{"key": "value"}'
value={config.extra_body ?? ""}
onChange={(event) => updateField("extra_body", event.target.value)}

View file

@ -1,8 +1,9 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogFooter, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { VisuallyHidden } from "radix-ui";
import { type ReactElement, useMemo } from "react";
import type { CanvasProcessorConfig } from "../types";
import { buildDefaultSchemaTransform } from "../utils/processors";
@ -65,8 +66,11 @@ export function ProcessorsDialog({
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="max-h-[85vh] overflow-auto sm:max-w-2xl"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl"
>
<VisuallyHidden.Root>
<DialogTitle>Processors</DialogTitle>
</VisuallyHidden.Root>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-3 py-2">
<div>
@ -106,7 +110,7 @@ export function ProcessorsDialog({
</label>
<Textarea
id={templateId}
className="nodrag min-h-[220px]"
className="corner-squircle nodrag min-h-[220px]"
value={schemaProcessor.template}
onChange={(event) =>
updateSchema({ template: event.target.value })

View file

@ -1,8 +1,8 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { type ReactElement, useState } from "react";
import type { SamplerConfig } from "../../types";
import { ChipInput } from "../../components/chip-input";
import { NameField } from "../shared/name-field";
type CategoryDialogProps = {
@ -14,29 +14,11 @@ export function CategoryDialog({
config,
onUpdate,
}: CategoryDialogProps): ReactElement {
const [valueDraft, setValueDraft] = useState("");
const [conditionDraft, setConditionDraft] = useState("");
const [conditionalValueDrafts, setConditionalValueDrafts] = useState<
Record<string, string>
>({});
const valuesInputId = `${config.id}-values`;
const conditionInputId = `${config.id}-conditional-rule`;
const conditional = config.conditional_params ?? {};
const handleAddValue = () => {
const nextValue = valueDraft.trim();
if (!nextValue) {
return;
}
const values = config.values ? [...config.values] : [];
const weights = config.weights ? [...config.weights] : [];
values.push(nextValue);
weights.push(null);
onUpdate({ values, weights });
setValueDraft("");
};
const handleAddCondition = () => {
const condition = conditionDraft.trim();
if (!condition || conditional[condition]) {
@ -66,29 +48,6 @@ export function CategoryDialog({
});
};
const addConditionalValue = (condition: string) => {
const draft = conditionalValueDrafts[condition]?.trim();
if (!draft) {
return;
}
const current = conditional[condition] ?? {
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category" as const,
values: [],
weights: [],
};
const values = [...(current.values ?? []), draft];
const weights = [...(current.weights ?? []), null];
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...current, values, weights },
},
});
setConditionalValueDrafts((prev) => ({ ...prev, [condition]: "" }));
};
return (
<div className="space-y-4">
<NameField
@ -97,50 +56,25 @@ export function CategoryDialog({
/>
<div className="space-y-3">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={valuesInputId}
>
<p className="text-xs font-semibold uppercase text-muted-foreground">
Values
</label>
<div className="flex gap-2">
<Input
id={valuesInputId}
className="nodrag"
placeholder="Add a value"
value={valueDraft}
onChange={(event) => setValueDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddValue();
}
}}
/>
<Button type="button" size="sm" onClick={handleAddValue}>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(config.values ?? []).map((value, index) => (
<Badge key={value} variant="secondary">
<span>{value}</span>
<button
type="button"
className="ml-2 text-xs"
onClick={() => {
const values = [...(config.values ?? [])];
const weights = [...(config.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
onUpdate({ values, weights });
}}
>
×
</button>
</Badge>
))}
</div>
</p>
<ChipInput
values={config.values ?? []}
onAdd={(value) => {
const values = [...(config.values ?? []), value];
const weights = [...(config.weights ?? []), null];
onUpdate({ values, weights });
}}
onRemove={(index) => {
const values = [...(config.values ?? [])];
const weights = [...(config.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
onUpdate({ values, weights });
}}
placeholder="Type a value and press Enter"
/>
</div>
<div className="grid gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
@ -149,12 +83,12 @@ export function CategoryDialog({
<div className="grid gap-2">
{(config.values ?? []).map((value, index) => (
<div key={`${value}-weight`} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-20 truncate">
<span className="text-xs text-muted-foreground max-w-20 truncate">
{value}
</span>
<Input
type="number"
className="nodrag"
className="nodrag w-full"
placeholder="Weight"
value={config.weights?.[index] ?? ""}
onChange={(event) => {
@ -213,61 +147,34 @@ export function CategoryDialog({
Remove
</Button>
</div>
<div className="flex gap-2">
<Input
className="nodrag"
placeholder="Add conditional value"
value={conditionalValueDrafts[condition] ?? ""}
onChange={(event) =>
setConditionalValueDrafts((prev) => ({
...prev,
[condition]: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addConditionalValue(condition);
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => addConditionalValue(condition)}
>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(params.values ?? []).map((value, index) => (
<Badge
key={`${condition}-${value}-${index}`}
variant="secondary"
>
<span>{value}</span>
<button
type="button"
className="ml-2 text-xs"
onClick={() => {
const values = [...(params.values ?? [])];
const weights = [...(params.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
>
×
</button>
</Badge>
))}
</div>
<ChipInput
values={params.values ?? []}
onAdd={(value) => {
const values = [...(params.values ?? []), value];
const weights = [...(params.weights ?? []), null];
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
onRemove={(index) => {
const values = [...(params.values ?? [])];
const weights = [...(params.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
placeholder="Type a conditional value and press Enter"
/>
<div className="grid gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Rule weights (optional)

View file

@ -1,6 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@ -8,14 +5,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { type ReactElement, useCallback, useEffect, useMemo } from "react";
import type { SamplerConfig } from "../../types";
import { ChipInput } from "../../components/chip-input";
import { NameField } from "../shared/name-field";
type SubcategoryDialogProps = {
@ -29,7 +21,6 @@ export function SubcategoryDialog({
categoryOptions,
onUpdate,
}: SubcategoryDialogProps): ReactElement {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const parentSelectId = `${config.id}-parent-category`;
const updateField = useCallback(
<K extends keyof SamplerConfig>(key: K, value: SamplerConfig[K]) => {
@ -72,19 +63,6 @@ export function SubcategoryDialog({
}
}, [ensureMapping, parent]);
const addSubValue = (categoryValue: string) => {
const draft = drafts[categoryValue]?.trim();
if (!draft) {
return;
}
const next = { ...mapping };
const list = next[categoryValue] ? [...next[categoryValue]] : [];
list.push(draft);
next[categoryValue] = list;
updateField("subcategory_mapping", next);
setDrafts((prev) => ({ ...prev, [categoryValue]: "" }));
};
return (
<div className="space-y-4">
<NameField
@ -119,15 +97,15 @@ export function SubcategoryDialog({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Map each parent category value to its subcategory options below.
</p>
</div>
{categoryValues.length > 0 && (
<div className="grid gap-4">
{categoryValues.map((value) => (
<div
key={value}
className="rounded-2xl border border-border/60 p-3"
>
<div className="flex items-center justify-between gap-2">
<div key={value}>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">
{value}
</p>
@ -135,52 +113,24 @@ export function SubcategoryDialog({
{mapping[value]?.length ?? 0} subvalues
</span>
</div>
<div className="mt-3 flex gap-2">
<Input
className="nodrag"
placeholder="Add subcategory"
value={drafts[value] ?? ""}
onChange={(event) =>
setDrafts((prev) => ({
...prev,
[value]: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addSubValue(value);
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => addSubValue(value)}
>
Add
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{(mapping[value] ?? []).map((item, index) => (
<Badge key={`${value}-${item}`} variant="secondary">
<span>{item}</span>
<button
type="button"
className="ml-2 text-xs"
onClick={() => {
const next = { ...mapping };
const list = [...(next[value] ?? [])];
list.splice(index, 1);
next[value] = list;
updateField("subcategory_mapping", next);
}}
>
×
</button>
</Badge>
))}
</div>
<ChipInput
values={mapping[value] ?? []}
onAdd={(item) => {
const next = { ...mapping };
const list = next[value] ? [...next[value]] : [];
list.push(item);
next[value] = list;
updateField("subcategory_mapping", next);
}}
onRemove={(index) => {
const next = { ...mapping };
const list = [...(next[value] ?? [])];
list.splice(index, 1);
next[value] = list;
updateField("subcategory_mapping", next);
}}
placeholder="Type subcategory and press Enter"
/>
{(mapping[value] ?? []).length === 0 && (
<p className="mt-2 text-xs text-rose-500">
Add at least 1 subcategory.

View file

@ -12,13 +12,9 @@ export function ValidationBanner({
return null;
}
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
<p className="font-semibold">Fix before run</p>
<ul className="mt-1 list-disc pl-4">
{errors.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<p className="text-xs text-amber-600">
<span className="font-semibold">Fix before run: </span>
{errors.join(". ")}.
</p>
);
}

View file

@ -0,0 +1,26 @@
import type { NodeConfig } from "../types";
export function getAvailableVariables(
configs: Record<string, NodeConfig>,
currentId: string,
): string[] {
const vars: string[] = [];
for (const config of Object.values(configs)) {
if (config.id === currentId) continue;
if (config.kind === "model_provider" || config.kind === "model_config") continue;
vars.push(config.name);
if (config.kind === "llm" && config.llm_type === "structured" && config.output_format) {
try {
const schema = JSON.parse(config.output_format);
if (schema.properties) {
for (const key of Object.keys(schema.properties)) {
vars.push(`${config.name}.${key}`);
}
}
} catch {
/* skip invalid JSON */
}
}
}
return vars;
}