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:
parent
b708ebb84c
commit
79614f0066
18 changed files with 454 additions and 301 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
26
studio/frontend/src/features/canvas-lab/utils/variables.ts
Normal file
26
studio/frontend/src/features/canvas-lab/utils/variables.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue