From 444169860c3a17108eebc8bc62cbc3e10116e3f3 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 27 Feb 2026 14:33:32 +0700 Subject: [PATCH] feat: introduce `InfoLabel`, `ToolNameSelect`, `ProviderModelSelect`, `TagInput`, and `KeyValueEditor` components and integrate them into various configuration pages for enhanced user experience. --- ui/web/src/components/shared/info-label.tsx | 27 +++ .../components/shared/key-value-editor.tsx | 106 ++++++++++ .../shared/provider-model-select.tsx | 128 +++++++++++++ ui/web/src/components/shared/tag-input.tsx | 78 ++++++++ .../components/shared/tool-name-select.tsx | 181 ++++++++++++++++++ ui/web/src/index.css | 28 ++- .../src/pages/agents/agent-create-dialog.tsx | 2 +- .../agents/agent-detail/agent-config-tab.tsx | 4 +- .../agents/agent-detail/agent-general-tab.tsx | 4 +- .../config-sections/config-section.tsx | 27 +-- .../config-sections/memory-section.tsx | 31 ++- .../config-sections/other-config-section.tsx | 33 +++- .../config-sections/subagents-section.tsx | 20 +- .../config-sections/tool-policy-section.tsx | 34 ++-- .../general-sections/llm-config-section.tsx | 2 +- .../builtin-tool-settings-dialog.tsx | 41 +++- .../sections/agents-defaults-section.tsx | 97 +++++----- .../pages/config/sections/cron-section.tsx | 8 +- .../pages/config/sections/gateway-section.tsx | 37 ++-- .../config/sections/sessions-section.tsx | 10 +- .../config/sections/telemetry-section.tsx | 42 ++-- .../pages/config/sections/tools-section.tsx | 45 ++--- ui/web/tsconfig.tsbuildinfo | 2 +- 23 files changed, 782 insertions(+), 205 deletions(-) create mode 100644 ui/web/src/components/shared/info-label.tsx create mode 100644 ui/web/src/components/shared/key-value-editor.tsx create mode 100644 ui/web/src/components/shared/provider-model-select.tsx create mode 100644 ui/web/src/components/shared/tag-input.tsx create mode 100644 ui/web/src/components/shared/tool-name-select.tsx diff --git a/ui/web/src/components/shared/info-label.tsx b/ui/web/src/components/shared/info-label.tsx new file mode 100644 index 00000000..36a75480 --- /dev/null +++ b/ui/web/src/components/shared/info-label.tsx @@ -0,0 +1,27 @@ +import { Info } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +/** Label with an (i) tooltip icon for field descriptions. */ +export function InfoLabel({ children, tip }: { children: React.ReactNode; tip: string }) { + return ( +
+ + + + + + + + {tip} + + + +
+ ); +} diff --git a/ui/web/src/components/shared/key-value-editor.tsx b/ui/web/src/components/shared/key-value-editor.tsx new file mode 100644 index 00000000..3a069112 --- /dev/null +++ b/ui/web/src/components/shared/key-value-editor.tsx @@ -0,0 +1,106 @@ +import { useState, useEffect, useRef } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +interface KeyValuePair { + key: string; + value: string; +} + +interface KeyValueEditorProps { + value: Record; + onChange: (value: Record) => void; + keyPlaceholder?: string; + valuePlaceholder?: string; + addLabel?: string; +} + +function toEntries(obj: Record): KeyValuePair[] { + const entries = Object.entries(obj).map(([key, value]) => ({ key, value })); + return entries.length > 0 ? entries : [{ key: "", value: "" }]; +} + +function toObject(entries: KeyValuePair[]): Record { + const result: Record = {}; + for (const { key, value } of entries) { + if (key.trim()) { + result[key.trim()] = value; + } + } + return result; +} + +export function KeyValueEditor({ + value, + onChange, + keyPlaceholder = "Key", + valuePlaceholder = "Value", + addLabel = "Add", +}: KeyValueEditorProps) { + const [entries, setEntries] = useState(() => toEntries(value)); + const internalChange = useRef(false); + + // Sync from external value changes only (not our own onChange calls) + useEffect(() => { + if (internalChange.current) { + internalChange.current = false; + return; + } + setEntries(toEntries(value)); + }, [value]); + + const emitChange = (next: KeyValuePair[]) => { + internalChange.current = true; + setEntries(next); + onChange(toObject(next)); + }; + + const updateEntry = (idx: number, patch: Partial) => { + const next = entries.map((e, i) => (i === idx ? { ...e, ...patch } : e)); + emitChange(next); + }; + + const addEntry = () => { + // Don't emit onChange — just add an empty row locally + setEntries((prev) => [...prev, { key: "", value: "" }]); + }; + + const removeEntry = (idx: number) => { + const next = entries.filter((_, i) => i !== idx); + const result = next.length > 0 ? next : [{ key: "", value: "" }]; + emitChange(result); + }; + + return ( +
+ {entries.map((entry, idx) => ( +
+ updateEntry(idx, { key: e.target.value })} + placeholder={keyPlaceholder} + className="flex-1 font-mono text-sm" + /> + updateEntry(idx, { value: e.target.value })} + placeholder={valuePlaceholder} + className="flex-1 font-mono text-sm" + /> + +
+ ))} + +
+ ); +} diff --git a/ui/web/src/components/shared/provider-model-select.tsx b/ui/web/src/components/shared/provider-model-select.tsx new file mode 100644 index 00000000..bde3c182 --- /dev/null +++ b/ui/web/src/components/shared/provider-model-select.tsx @@ -0,0 +1,128 @@ +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Combobox } from "@/components/ui/combobox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useProviders } from "@/pages/providers/hooks/use-providers"; +import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; +import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify"; +import { InfoLabel } from "./info-label"; + +interface ProviderModelSelectProps { + provider: string; + onProviderChange: (v: string) => void; + model: string; + onModelChange: (v: string) => void; + providerTip?: string; + modelTip?: string; + providerLabel?: string; + modelLabel?: string; + providerPlaceholder?: string; + modelPlaceholder?: string; + /** Show a "Check" verify button. */ + showVerify?: boolean; +} + +export function ProviderModelSelect({ + provider, + onProviderChange, + model, + onModelChange, + providerTip = "LLM provider name. Must match a configured provider.", + modelTip = "Model ID to use.", + providerLabel = "Provider", + modelLabel = "Model", + providerPlaceholder = "Select provider", + modelPlaceholder = "Enter or select model", + showVerify = false, +}: ProviderModelSelectProps) { + const { providers } = useProviders(); + const enabledProviders = providers.filter((p) => p.enabled); + + const selectedProviderId = useMemo( + () => enabledProviders.find((p) => p.name === provider)?.id, + [enabledProviders, provider], + ); + const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); + const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); + + const handleProviderChange = (v: string) => { + onProviderChange(v); + onModelChange(""); + resetVerify(); + }; + + const handleModelChange = (v: string) => { + onModelChange(v); + resetVerify(); + }; + + const handleVerify = async () => { + if (!selectedProviderId || !model.trim()) return; + await verify(selectedProviderId, model.trim()); + }; + + return ( +
+
+ {providerLabel} + {enabledProviders.length > 0 ? ( + + ) : ( + handleProviderChange(e.target.value)} + placeholder="No providers configured" + /> + )} +
+
+ {modelLabel} +
+
+ ({ value: m.id, label: m.name }))} + placeholder={modelsLoading ? "Loading models..." : modelPlaceholder} + /> +
+ {showVerify && ( + + )} +
+ {showVerify && verifyResult && ( +

+ {verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"} +

+ )} +
+
+ ); +} diff --git a/ui/web/src/components/shared/tag-input.tsx b/ui/web/src/components/shared/tag-input.tsx new file mode 100644 index 00000000..1e20ba0f --- /dev/null +++ b/ui/web/src/components/shared/tag-input.tsx @@ -0,0 +1,78 @@ +import { useState, useRef } from "react"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface TagInputProps { + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; + className?: string; +} + +export function TagInput({ + value, + onChange, + placeholder = "Type and press Enter...", + className, +}: TagInputProps) { + const [input, setInput] = useState(""); + const inputRef = useRef(null); + + const addTag = (tag: string) => { + const trimmed = tag.trim(); + if (trimmed && !value.includes(trimmed)) { + onChange([...value, trimmed]); + } + setInput(""); + }; + + const removeTag = (tag: string) => { + onChange(value.filter((v) => v !== tag)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(input); + } + if (e.key === "Backspace" && !input && value.length > 0) { + removeTag(value[value.length - 1]!); + } + }; + + return ( +
inputRef.current?.focus()} + > + {value.map((tag) => ( + + {tag} + + + ))} + setInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (input.trim()) addTag(input); }} + placeholder={value.length === 0 ? placeholder : ""} + className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none" + /> +
+ ); +} diff --git a/ui/web/src/components/shared/tool-name-select.tsx b/ui/web/src/components/shared/tool-name-select.tsx new file mode 100644 index 00000000..9df34b04 --- /dev/null +++ b/ui/web/src/components/shared/tool-name-select.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState, useRef, useEffect } from "react"; +import { X, ChevronDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useBuiltinTools } from "@/pages/builtin-tools/hooks/use-builtin-tools"; +import { useCustomTools } from "@/pages/custom-tools/hooks/use-custom-tools"; + +interface ToolNameSelectProps { + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; + className?: string; +} + +interface ToolOption { + name: string; + displayName: string; + group: "built-in" | "custom"; +} + +export function ToolNameSelect({ + value, + onChange, + placeholder = "Select or type tool names...", + className, +}: ToolNameSelectProps) { + const { tools: builtinTools } = useBuiltinTools(); + const { tools: customTools } = useCustomTools(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const allTools = useMemo(() => { + const builtin: ToolOption[] = builtinTools.map((t) => ({ + name: t.name, + displayName: t.display_name || t.name, + group: "built-in", + })); + const custom: ToolOption[] = customTools.map((t) => ({ + name: t.name, + displayName: t.name, + group: "custom", + })); + return [...builtin, ...custom]; + }, [builtinTools, customTools]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return allTools + .filter((t) => !value.includes(t.name)) + .filter((t) => !q || t.name.toLowerCase().includes(q) || t.displayName.toLowerCase().includes(q)); + }, [allTools, value, search]); + + const grouped = useMemo(() => { + const builtinGroup = filtered.filter((t) => t.group === "built-in"); + const customGroup = filtered.filter((t) => t.group === "custom"); + return { builtin: builtinGroup, custom: customGroup }; + }, [filtered]); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const addTool = (name: string) => { + if (!value.includes(name)) { + onChange([...value, name]); + } + setSearch(""); + inputRef.current?.focus(); + }; + + const removeTool = (name: string) => { + onChange(value.filter((v) => v !== name)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const trimmed = search.trim().replace(/,$/, ""); + if (trimmed) { + addTool(trimmed); + } + } + if (e.key === "Backspace" && !search && value.length > 0) { + removeTool(value[value.length - 1]!); + } + }; + + return ( +
+
inputRef.current?.focus()} + > + {value.map((name) => ( + + {name} + + + ))} + { + setSearch(e.target.value); + if (!open) setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + placeholder={value.length === 0 ? placeholder : ""} + className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none" + /> + setOpen(!open)} + /> +
+ {open && (grouped.builtin.length > 0 || grouped.custom.length > 0) && ( +
+ {grouped.builtin.length > 0 && ( + <> +
+ Built-in +
+ {grouped.builtin.map((t) => ( + + ))} + + )} + {grouped.custom.length > 0 && ( + <> +
+ Custom +
+ {grouped.custom.map((t) => ( + + ))} + + )} +
+ )} +
+ ); +} diff --git a/ui/web/src/index.css b/ui/web/src/index.css index d780d789..ab43c1c4 100644 --- a/ui/web/src/index.css +++ b/ui/web/src/index.css @@ -32,6 +32,10 @@ --sidebar-accent-foreground: oklch(0.511 0.262 276.966); --sidebar-border: oklch(0.885 0.02 255.508); --sidebar-ring: oklch(0.511 0.262 276.966); + --success: oklch(0.55 0.2 145); + --success-foreground: oklch(0.98 0 0); + --warning: oklch(0.75 0.18 75); + --warning-foreground: oklch(0.25 0.05 75); --chart-1: oklch(0.585 0.233 277.117); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); @@ -48,24 +52,28 @@ --popover-foreground: oklch(0.984 0.003 247.858); --primary: oklch(0.585 0.233 277.117); --primary-foreground: oklch(0.985 0.002 247); - --secondary: oklch(0.279 0.041 260.031); + --secondary: oklch(0.240 0.035 260); --secondary-foreground: oklch(0.984 0.003 247.858); - --muted: oklch(0.279 0.041 260.031); - --muted-foreground: oklch(0.704 0.04 256.788); - --accent: oklch(0.279 0.041 260.031); + --muted: oklch(0.220 0.030 260); + --muted-foreground: oklch(0.650 0.030 256); + --accent: oklch(0.300 0.055 270); --accent-foreground: oklch(0.984 0.003 247.858); --destructive: oklch(0.396 0.141 25.723); --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(0.279 0.041 260.031); - --input: oklch(0.279 0.041 260.031); + --border: oklch(0.320 0.025 260); + --input: oklch(0.250 0.035 260); --ring: oklch(0.585 0.233 277.117); + --success: oklch(0.45 0.15 145); + --success-foreground: oklch(0.85 0.1 145); + --warning: oklch(0.55 0.15 75); + --warning-foreground: oklch(0.85 0.1 75); --sidebar: oklch(0.168 0.042 264.695); --sidebar-foreground: oklch(0.984 0.003 247.858); --sidebar-primary: oklch(0.585 0.233 277.117); --sidebar-primary-foreground: oklch(0.985 0.002 247); - --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent: oklch(0.300 0.055 270); --sidebar-accent-foreground: oklch(0.984 0.003 247.858); - --sidebar-border: oklch(0.279 0.041 260.031); + --sidebar-border: oklch(0.320 0.025 260); --sidebar-ring: oklch(0.585 0.233 277.117); --chart-1: oklch(0.673 0.182 276.935); --chart-2: oklch(0.696 0.17 162.48); @@ -106,6 +114,10 @@ --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); --color-chart-5: var(--chart-5); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index faa2e31b..20fd2f49 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -178,7 +178,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD {verifyResult && ( -

+

{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}

)} diff --git a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx index 06465d96..048ffd19 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx @@ -99,7 +99,7 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { }; return ( -
+
{saved && ( - + Saved )} diff --git a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx index 86e9b5bb..1fa6d46f 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx @@ -62,7 +62,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { }; return ( -
+
{saved && ( - + Saved )} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/config-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/config-section.tsx index 3b605d1b..036b759a 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/config-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/config-section.tsx @@ -1,31 +1,6 @@ -import { Info } from "lucide-react"; -import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -/** Label with an (i) tooltip icon for field descriptions. */ -export function InfoLabel({ children, tip }: { children: React.ReactNode; tip: string }) { - return ( -
- - - - - - - - {tip} - - - -
- ); -} +export { InfoLabel } from "@/components/shared/info-label"; interface ConfigSectionProps { title: string; diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/memory-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/memory-section.tsx index 3baa1856..22e03b21 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/memory-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/memory-section.tsx @@ -2,6 +2,7 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import type { MemoryConfig } from "@/types/agent"; import { ConfigSection, InfoLabel, numOrUndef } from "./config-section"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; interface MemorySectionProps { enabled: boolean; @@ -25,24 +26,18 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect /> Enabled
-
-
- Embedding Provider - onChange({ ...value, embedding_provider: e.target.value || undefined })} - /> -
-
- Embedding Model - onChange({ ...value, embedding_model: e.target.value || undefined })} - /> -
-
+ onChange({ ...value, embedding_provider: v || undefined })} + model={value.embedding_model ?? ""} + onModelChange={(v) => onChange({ ...value, embedding_model: v || undefined })} + providerLabel="Embedding Provider" + modelLabel="Embedding Model" + providerTip="LLM provider used for generating text embeddings. Leave empty to auto-detect from the agent's main provider." + modelTip="Embedding model name (e.g. text-embedding-3-small). Must be supported by the provider." + providerPlaceholder="(auto)" + modelPlaceholder="text-embedding-3-small" + />
Max Results diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/other-config-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/other-config-section.tsx index 22002770..05113bc6 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/other-config-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/other-config-section.tsx @@ -1,4 +1,6 @@ +import { useState, useEffect } from "react"; import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; import { ConfigSection } from "./config-section"; interface OtherConfigSectionProps { @@ -9,19 +11,46 @@ interface OtherConfigSectionProps { } export function OtherConfigSection({ enabled, value, onToggle, onChange }: OtherConfigSectionProps) { + const [validJson, setValidJson] = useState(true); + + useEffect(() => { + try { + JSON.parse(value || "{}"); + setValidJson(true); + } catch { + setValidJson(false); + } + }, [value]); + + const handleFormat = () => { + try { + const parsed = JSON.parse(value); + onChange(JSON.stringify(parsed, null, 2)); + } catch { + // can't format invalid JSON + } + }; + return (