mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
feat: introduce InfoLabel, ToolNameSelect, ProviderModelSelect, TagInput, and KeyValueEditor components and integrate them into various configuration pages for enhanced user experience.
This commit is contained in:
1 parent
0105bab2d5
commit
444169860c
23 files changed
+782
-205
No files matched your search
@@ -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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>{children}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 shrink-0 cursor-help text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
{tip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
addLabel?: string;
|
||||
}
|
||||
|
||||
function toEntries(obj: Record<string, string>): KeyValuePair[] {
|
||||
const entries = Object.entries(obj).map(([key, value]) => ({ key, value }));
|
||||
return entries.length > 0 ? entries : [{ key: "", value: "" }];
|
||||
}
|
||||
|
||||
function toObject(entries: KeyValuePair[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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<KeyValuePair[]>(() => 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<KeyValuePair>) => {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEntry(idx, { key: e.target.value })}
|
||||
placeholder={keyPlaceholder}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(idx, { value: e.target.value })}
|
||||
placeholder={valuePlaceholder}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => removeEntry(idx)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={addEntry} className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" /> {addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={providerTip}>{providerLabel}</InfoLabel>
|
||||
{enabledProviders.length > 0 ? (
|
||||
<Select value={provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={providerPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledProviders.map((p) => (
|
||||
<SelectItem key={p.name} value={p.name}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={provider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
placeholder="No providers configured"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={modelTip}>{modelLabel}</InfoLabel>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={handleModelChange}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : modelPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
{showVerify && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 shrink-0 px-3"
|
||||
disabled={!selectedProviderId || !model.trim() || verifying}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{verifying ? "..." : "Check"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showVerify && verifyResult && (
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
|
||||
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addTag(input);
|
||||
}
|
||||
if (e.key === "Backspace" && !input && value.length > 0) {
|
||||
removeTag(value[value.length - 1]!);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
className,
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-destructive ml-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allTools = useMemo<ToolOption[]>(() => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-destructive ml-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); removeTool(name); }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
</div>
|
||||
{open && (grouped.builtin.length > 0 || grouped.custom.length > 0) && (
|
||||
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border p-1 shadow-md">
|
||||
{grouped.builtin.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-[10px] font-semibold uppercase tracking-wider">
|
||||
Built-in
|
||||
</div>
|
||||
{grouped.builtin.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => addTool(t.name)}
|
||||
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
<span className="truncate">{t.displayName}</span>
|
||||
<code className="text-muted-foreground text-[10px]">{t.name}</code>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{grouped.custom.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground mt-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider">
|
||||
Custom
|
||||
</div>
|
||||
{grouped.custom.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => addTool(t.name)}
|
||||
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
<span className="truncate">{t.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-8
@@ -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);
|
||||
|
||||
@@ -178,7 +178,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
</Button>
|
||||
</div>
|
||||
{verifyResult && (
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
|
||||
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<SubagentsSection
|
||||
enabled={subEnabled}
|
||||
value={sub}
|
||||
@@ -164,7 +164,7 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-sm text-green-600">
|
||||
<span className="flex items-center gap-1 text-sm text-success">
|
||||
<Check className="h-3.5 w-3.5" /> Saved
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<IdentitySection
|
||||
agentKey={agent.agent_key}
|
||||
displayName={displayName}
|
||||
@@ -108,7 +108,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-sm text-green-600">
|
||||
<span className="flex items-center gap-1 text-sm text-success">
|
||||
<Check className="h-3.5 w-3.5" /> Saved
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>{children}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 shrink-0 cursor-help text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
{tip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { InfoLabel } from "@/components/shared/info-label";
|
||||
|
||||
interface ConfigSectionProps {
|
||||
title: string;
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
<InfoLabel tip="Enable or disable the memory system for this agent. When enabled, the agent can store and recall information across sessions.">Enabled</InfoLabel>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="LLM provider used for generating text embeddings. Leave empty to auto-detect from the agent's main provider.">Embedding Provider</InfoLabel>
|
||||
<Input
|
||||
placeholder="(auto)"
|
||||
value={value.embedding_provider ?? ""}
|
||||
onChange={(e) => onChange({ ...value, embedding_provider: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Embedding model name. Must be supported by the embedding provider (e.g. text-embedding-3-small for OpenAI).">Embedding Model</InfoLabel>
|
||||
<Input
|
||||
placeholder="text-embedding-3-small"
|
||||
value={value.embedding_model ?? ""}
|
||||
onChange={(e) => onChange({ ...value, embedding_model: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderModelSelect
|
||||
provider={value.embedding_provider ?? ""}
|
||||
onProviderChange={(v) => 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"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Maximum number of memory entries returned per search query.">Max Results</InfoLabel>
|
||||
|
||||
@@ -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 (
|
||||
<ConfigSection
|
||||
title="Other Config"
|
||||
description="Generic JSON config (no schema)"
|
||||
description="Additional JSON config for advanced settings not covered by other sections"
|
||||
enabled={enabled}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<Textarea
|
||||
className="font-mono text-sm"
|
||||
className={`font-mono text-sm ${!validJson ? "border-destructive" : ""}`}
|
||||
rows={6}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder='{ "key": "value" }'
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={handleFormat} disabled={!validJson} className="h-7 px-2 text-xs">
|
||||
Format JSON
|
||||
</Button>
|
||||
{!validJson && <span className="text-xs text-destructive">Invalid JSON syntax</span>}
|
||||
</div>
|
||||
</ConfigSection>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { SubagentsConfig } from "@/types/agent";
|
||||
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
|
||||
|
||||
interface SubagentsSectionProps {
|
||||
enabled: boolean;
|
||||
@@ -17,6 +21,15 @@ interface SubagentsSectionProps {
|
||||
}
|
||||
|
||||
export function SubagentsSection({ enabled, value, onToggle, onChange }: SubagentsSectionProps) {
|
||||
const { providers } = useProviders();
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
// Use the first enabled provider for model list suggestions
|
||||
const defaultProviderId = useMemo(
|
||||
() => enabledProviders[0]?.id,
|
||||
[enabledProviders],
|
||||
);
|
||||
const { models, loading: modelsLoading } = useProviderModels(defaultProviderId);
|
||||
|
||||
return (
|
||||
<ConfigSection
|
||||
title="Subagents"
|
||||
@@ -71,10 +84,11 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="LLM model for sub-agents. Leave empty to inherit the parent agent's model.">Model Override</InfoLabel>
|
||||
<Input
|
||||
placeholder="(inherit from agent)"
|
||||
<Combobox
|
||||
value={value.model ?? ""}
|
||||
onChange={(e) => onChange({ ...value, model: e.target.value || undefined })}
|
||||
onChange={(v) => onChange({ ...value, model: v || undefined })}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "(inherit from agent)"}
|
||||
/>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -7,7 +6,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ToolPolicyConfig } from "@/types/agent";
|
||||
import { ConfigSection, InfoLabel, arrayToTags, tagsToArray } from "./config-section";
|
||||
import { ConfigSection, InfoLabel } from "./config-section";
|
||||
import { ToolNameSelect } from "@/components/shared/tool-name-select";
|
||||
|
||||
interface ToolPolicySectionProps {
|
||||
enabled: boolean;
|
||||
@@ -39,27 +39,27 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Comma-separated allowlist. Only these tools will be available (overrides profile). Leave empty to use profile defaults.">Allow</InfoLabel>
|
||||
<Input
|
||||
placeholder="tool1, tool2, ..."
|
||||
value={arrayToTags(value.allow)}
|
||||
onChange={(e) => onChange({ ...value, allow: tagsToArray(e.target.value) })}
|
||||
<InfoLabel tip="Explicit allowlist. Only these tools will be available (overrides profile). Leave empty to use profile defaults.">Allow</InfoLabel>
|
||||
<ToolNameSelect
|
||||
value={value.allow ?? []}
|
||||
onChange={(v) => onChange({ ...value, allow: v.length > 0 ? v : undefined })}
|
||||
placeholder="Select tools to allow..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Comma-separated denylist. These tools will be blocked even if allowed by the profile.">Deny</InfoLabel>
|
||||
<Input
|
||||
placeholder="tool1, tool2, ..."
|
||||
value={arrayToTags(value.deny)}
|
||||
onChange={(e) => onChange({ ...value, deny: tagsToArray(e.target.value) })}
|
||||
<InfoLabel tip="Denylist. These tools will be blocked even if allowed by the profile.">Deny</InfoLabel>
|
||||
<ToolNameSelect
|
||||
value={value.deny ?? []}
|
||||
onChange={(v) => onChange({ ...value, deny: v.length > 0 ? v : undefined })}
|
||||
placeholder="Select tools to deny..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Additional tools added on top of the profile defaults. Useful for enabling optional tools like web_fetch without overriding the whole profile.">Also Allow</InfoLabel>
|
||||
<Input
|
||||
placeholder="web_fetch, web_search, ..."
|
||||
value={arrayToTags(value.alsoAllow)}
|
||||
onChange={(e) => onChange({ ...value, alsoAllow: tagsToArray(e.target.value) })}
|
||||
<InfoLabel tip="Additional tools on top of profile defaults. Useful for enabling optional tools without overriding the whole profile.">Also Allow</InfoLabel>
|
||||
<ToolNameSelect
|
||||
value={value.alsoAllow ?? []}
|
||||
onChange={(v) => onChange({ ...value, alsoAllow: v.length > 0 ? v : undefined })}
|
||||
placeholder="Select additional tools..."
|
||||
/>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
@@ -127,7 +127,7 @@ export function LlmConfigSection({
|
||||
)}
|
||||
</div>
|
||||
{verifyResult && (
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
|
||||
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -163,7 +163,7 @@ function MediaSettingsForm({
|
||||
</Button>
|
||||
</div>
|
||||
{verifyResult && (
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-emerald-500" : "text-red-500"}`}>
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
|
||||
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
|
||||
</p>
|
||||
)}
|
||||
@@ -193,14 +193,38 @@ function JsonSettingsForm({
|
||||
const [json, setJson] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [validJson, setValidJson] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (tool) {
|
||||
setJson(JSON.stringify(tool.settings ?? {}, null, 2));
|
||||
setError("");
|
||||
setValidJson(true);
|
||||
}
|
||||
}, [tool]);
|
||||
|
||||
const handleJsonChange = (text: string) => {
|
||||
setJson(text);
|
||||
try {
|
||||
JSON.parse(text);
|
||||
setValidJson(true);
|
||||
setError("");
|
||||
} catch {
|
||||
setValidJson(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
setJson(JSON.stringify(parsed, null, 2));
|
||||
setError("");
|
||||
setValidJson(true);
|
||||
} catch {
|
||||
setError("Cannot format: invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!tool) return;
|
||||
try {
|
||||
@@ -220,21 +244,30 @@ function JsonSettingsForm({
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings: {tool?.display_name ?? tool?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit tool-specific settings as JSON. Changes take effect immediately after saving.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={json}
|
||||
onChange={(e) => setJson(e.target.value)}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
className={`font-mono text-sm ${!validJson ? "border-destructive" : ""}`}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={handleFormat} className="h-7 px-2 text-xs">
|
||||
Format JSON
|
||||
</Button>
|
||||
{!validJson && <span className="text-xs text-destructive">Invalid JSON syntax</span>}
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button onClick={handleSave} disabled={saving || !validJson}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type AgentsData = Record<string, any>;
|
||||
@@ -72,28 +74,19 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Core fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Provider</Label>
|
||||
<Input
|
||||
value={defaults.provider ?? ""}
|
||||
onChange={(e) => updateDefaults({ provider: e.target.value })}
|
||||
placeholder="anthropic"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Model</Label>
|
||||
<Input
|
||||
value={defaults.model ?? ""}
|
||||
onChange={(e) => updateDefaults({ model: e.target.value })}
|
||||
placeholder="claude-sonnet-4-5-20250929"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderModelSelect
|
||||
provider={defaults.provider ?? ""}
|
||||
onProviderChange={(v) => updateDefaults({ provider: v })}
|
||||
model={defaults.model ?? ""}
|
||||
onModelChange={(v) => updateDefaults({ model: v })}
|
||||
providerTip="Default LLM provider for all agents. Must match a configured provider name."
|
||||
modelTip="Default model ID for all agents. Agents can override this in their own settings."
|
||||
showVerify
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Max Tokens</Label>
|
||||
<InfoLabel tip="Maximum output tokens per LLM response. Higher values allow longer responses.">Max Tokens</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaults.max_tokens ?? ""}
|
||||
@@ -102,7 +95,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Temperature</Label>
|
||||
<InfoLabel tip="Sampling temperature (0-2). Lower = more deterministic, higher = more creative.">Temperature</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
@@ -114,7 +107,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Max Tool Iterations</Label>
|
||||
<InfoLabel tip="Maximum number of tool calls per agent request before stopping.">Max Tool Iterations</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaults.max_tool_iterations ?? ""}
|
||||
@@ -123,7 +116,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Context Window</Label>
|
||||
<InfoLabel tip="Maximum context size in tokens. Should match the model's context window limit.">Context Window</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaults.context_window ?? ""}
|
||||
@@ -135,7 +128,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Workspace</Label>
|
||||
<InfoLabel tip="Default filesystem workspace path for agent file operations.">Workspace</InfoLabel>
|
||||
<Input
|
||||
value={defaults.workspace ?? ""}
|
||||
onChange={(e) => updateDefaults({ workspace: e.target.value })}
|
||||
@@ -143,7 +136,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Restrict to Workspace</Label>
|
||||
<InfoLabel tip="When enabled, agents can only read/write files within the workspace directory.">Restrict to Workspace</InfoLabel>
|
||||
<Switch
|
||||
checked={defaults.restrict_to_workspace ?? false}
|
||||
onCheckedChange={(v) => updateDefaults({ restrict_to_workspace: v })}
|
||||
@@ -159,12 +152,12 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
onToggle={() => toggleSub("subagents")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Max Concurrent" type="number" value={subagents.maxConcurrent} onChange={(v) => updateNested("subagents", { maxConcurrent: Number(v) })} placeholder="20" />
|
||||
<Field label="Max Spawn Depth" type="number" value={subagents.maxSpawnDepth} onChange={(v) => updateNested("subagents", { maxSpawnDepth: Number(v) })} placeholder="1" />
|
||||
<Field label="Max Children/Agent" type="number" value={subagents.maxChildrenPerAgent} onChange={(v) => updateNested("subagents", { maxChildrenPerAgent: Number(v) })} placeholder="5" />
|
||||
<Field label="Archive After (min)" type="number" value={subagents.archiveAfterMinutes} onChange={(v) => updateNested("subagents", { archiveAfterMinutes: Number(v) })} placeholder="60" />
|
||||
<Field label="Max Concurrent" tip="Maximum subagents running at the same time." type="number" value={subagents.maxConcurrent} onChange={(v) => updateNested("subagents", { maxConcurrent: Number(v) })} placeholder="20" />
|
||||
<Field label="Max Spawn Depth" tip="Maximum nesting depth for subagent spawning (agent → subagent → sub-subagent)." type="number" value={subagents.maxSpawnDepth} onChange={(v) => updateNested("subagents", { maxSpawnDepth: Number(v) })} placeholder="1" />
|
||||
<Field label="Max Children/Agent" tip="Maximum child subagents a single agent can spawn." type="number" value={subagents.maxChildrenPerAgent} onChange={(v) => updateNested("subagents", { maxChildrenPerAgent: Number(v) })} placeholder="5" />
|
||||
<Field label="Archive After (min)" tip="Auto-archive idle subagent sessions after this many minutes." type="number" value={subagents.archiveAfterMinutes} onChange={(v) => updateNested("subagents", { archiveAfterMinutes: Number(v) })} placeholder="60" />
|
||||
</div>
|
||||
<Field label="Model Override" value={subagents.model} onChange={(v) => updateNested("subagents", { model: v })} placeholder="Use default" />
|
||||
<Field label="Model Override" tip="Use a different model for subagents. Leave empty to inherit the parent agent's model." value={subagents.model} onChange={(v) => updateNested("subagents", { model: v })} placeholder="Use default" />
|
||||
</SubSection>
|
||||
|
||||
<SubSection
|
||||
@@ -177,11 +170,21 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
<Label>Enabled</Label>
|
||||
<Switch checked={memory.enabled !== false} onCheckedChange={(v) => updateNested("memory", { enabled: v })} />
|
||||
</div>
|
||||
<ProviderModelSelect
|
||||
provider={memory.embedding_provider ?? ""}
|
||||
onProviderChange={(v) => updateNested("memory", { embedding_provider: v || undefined })}
|
||||
model={memory.embedding_model ?? ""}
|
||||
onModelChange={(v) => updateNested("memory", { embedding_model: v || undefined })}
|
||||
providerLabel="Embedding Provider"
|
||||
modelLabel="Embedding Model"
|
||||
providerTip="Provider for generating text embeddings. Auto-detects from agent's main provider if empty."
|
||||
modelTip="Embedding model name (e.g. text-embedding-3-small). Must be supported by the provider."
|
||||
providerPlaceholder="(auto)"
|
||||
modelPlaceholder="text-embedding-3-small"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Embedding Provider" value={memory.embedding_provider} onChange={(v) => updateNested("memory", { embedding_provider: v })} placeholder="auto" />
|
||||
<Field label="Embedding Model" value={memory.embedding_model} onChange={(v) => updateNested("memory", { embedding_model: v })} placeholder="text-embedding-3-small" />
|
||||
<Field label="Max Results" type="number" value={memory.max_results} onChange={(v) => updateNested("memory", { max_results: Number(v) })} placeholder="6" />
|
||||
<Field label="Min Score" type="number" step="0.01" value={memory.min_score} onChange={(v) => updateNested("memory", { min_score: Number(v) })} placeholder="0.35" />
|
||||
<Field label="Max Results" tip="Maximum memory entries returned per search query." type="number" value={memory.max_results} onChange={(v) => updateNested("memory", { max_results: Number(v) })} placeholder="6" />
|
||||
<Field label="Min Score" tip="Minimum similarity score (0-1) for memory search results. Lower = more results but less relevant." type="number" step="0.01" value={memory.min_score} onChange={(v) => updateNested("memory", { min_score: Number(v) })} placeholder="0.35" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
@@ -192,8 +195,8 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
onToggle={() => toggleSub("compaction")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Reserve Tokens Floor" type="number" value={compaction.reserveTokensFloor} onChange={(v) => updateNested("compaction", { reserveTokensFloor: Number(v) })} placeholder="20000" />
|
||||
<Field label="Max History Share" type="number" step="0.05" value={compaction.maxHistoryShare} onChange={(v) => updateNested("compaction", { maxHistoryShare: Number(v) })} placeholder="0.75" />
|
||||
<Field label="Reserve Tokens Floor" tip="Minimum tokens to reserve for new content after compaction. Prevents over-compaction." type="number" value={compaction.reserveTokensFloor} onChange={(v) => updateNested("compaction", { reserveTokensFloor: Number(v) })} placeholder="20000" />
|
||||
<Field label="Max History Share" tip="Maximum fraction (0-1) of context window that conversation history can occupy before compaction triggers." type="number" step="0.05" value={compaction.maxHistoryShare} onChange={(v) => updateNested("compaction", { maxHistoryShare: Number(v) })} placeholder="0.75" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
@@ -214,7 +217,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Field label="Keep Last Assistants" type="number" value={pruning.keepLastAssistants} onChange={(v) => updateNested("contextPruning", { keepLastAssistants: Number(v) })} placeholder="3" />
|
||||
<Field label="Keep Last Assistants" tip="Number of recent assistant messages to always keep when pruning context." type="number" value={pruning.keepLastAssistants} onChange={(v) => updateNested("contextPruning", { keepLastAssistants: Number(v) })} placeholder="3" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
@@ -225,10 +228,10 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
onToggle={() => toggleSub("heartbeat")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Every" value={heartbeat.every} onChange={(v) => updateNested("heartbeat", { every: v })} placeholder="30m (0m = disabled)" />
|
||||
<Field label="Model Override" value={heartbeat.model} onChange={(v) => updateNested("heartbeat", { model: v })} placeholder="Use default" />
|
||||
<Field label="Session" value={heartbeat.session} onChange={(v) => updateNested("heartbeat", { session: v })} placeholder="main" />
|
||||
<Field label="Target" value={heartbeat.target} onChange={(v) => updateNested("heartbeat", { target: v })} placeholder="last" />
|
||||
<Field label="Every" tip="How often the agent wakes up. Go duration format (e.g. 30m, 1h). 0m = disabled." value={heartbeat.every} onChange={(v) => updateNested("heartbeat", { every: v })} placeholder="30m (0m = disabled)" />
|
||||
<Field label="Model Override" tip="Use a different model for heartbeat checks. Leave empty for default." value={heartbeat.model} onChange={(v) => updateNested("heartbeat", { model: v })} placeholder="Use default" />
|
||||
<Field label="Session" tip="Session key to use for heartbeat messages." value={heartbeat.session} onChange={(v) => updateNested("heartbeat", { session: v })} placeholder="main" />
|
||||
<Field label="Target" tip="Target for heartbeat. 'last' resumes the last active session." value={heartbeat.target} onChange={(v) => updateNested("heartbeat", { target: v })} placeholder="last" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
@@ -250,10 +253,10 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Field label="Image" value={sandbox.image} onChange={(v) => updateNested("sandbox", { image: v })} placeholder="goclaw-sandbox:bookworm-slim" />
|
||||
<Field label="Memory (MB)" type="number" value={sandbox.memory_mb} onChange={(v) => updateNested("sandbox", { memory_mb: Number(v) })} placeholder="512" />
|
||||
<Field label="CPUs" type="number" step="0.5" value={sandbox.cpus} onChange={(v) => updateNested("sandbox", { cpus: Number(v) })} placeholder="1.0" />
|
||||
<Field label="Timeout (sec)" type="number" value={sandbox.timeout_sec} onChange={(v) => updateNested("sandbox", { timeout_sec: Number(v) })} placeholder="300" />
|
||||
<Field label="Image" tip="Docker image name for the sandbox container." value={sandbox.image} onChange={(v) => updateNested("sandbox", { image: v })} placeholder="goclaw-sandbox:bookworm-slim" />
|
||||
<Field label="Memory (MB)" tip="Memory limit in MB for the sandbox container." type="number" value={sandbox.memory_mb} onChange={(v) => updateNested("sandbox", { memory_mb: Number(v) })} placeholder="512" />
|
||||
<Field label="CPUs" tip="CPU limit for the sandbox container (fractional values allowed)." type="number" step="0.5" value={sandbox.cpus} onChange={(v) => updateNested("sandbox", { cpus: Number(v) })} placeholder="1.0" />
|
||||
<Field label="Timeout (sec)" tip="Maximum execution time in seconds before the sandbox is killed." type="number" value={sandbox.timeout_sec} onChange={(v) => updateNested("sandbox", { timeout_sec: Number(v) })} placeholder="300" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Network Enabled</Label>
|
||||
<Switch checked={sandbox.network_enabled ?? false} onCheckedChange={(v) => updateNested("sandbox", { network_enabled: v })} />
|
||||
@@ -301,13 +304,14 @@ function SubSection({
|
||||
<span className="ml-2 text-xs text-muted-foreground">{desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
{open && <div className="space-y-3 border-t px-3 py-3">{children}</div>}
|
||||
{open && <div className="space-y-3 border-t px-4 py-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
tip,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
@@ -315,6 +319,7 @@ function Field({
|
||||
step,
|
||||
}: {
|
||||
label: string;
|
||||
tip?: string;
|
||||
value: any;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
@@ -323,7 +328,7 @@ function Field({
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
{tip ? <InfoLabel tip={tip}>{label}</InfoLabel> : <Label>{label}</Label>}
|
||||
<Input
|
||||
type={type}
|
||||
step={step}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useEffect } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
|
||||
interface CronData {
|
||||
max_retries?: number;
|
||||
@@ -44,7 +44,7 @@ export function CronSection({ data, onSave, saving }: Props) {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Max Retries</Label>
|
||||
<InfoLabel tip="Maximum number of retry attempts for failed cron jobs before giving up.">Max Retries</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.max_retries ?? ""}
|
||||
@@ -54,7 +54,7 @@ export function CronSection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Base Delay</Label>
|
||||
<InfoLabel tip="Initial delay between retries with exponential backoff. Go duration format (e.g. 2s, 500ms).">Base Delay</InfoLabel>
|
||||
<Input
|
||||
value={draft.retry_base_delay ?? ""}
|
||||
onChange={(e) => update({ retry_base_delay: e.target.value })}
|
||||
@@ -62,7 +62,7 @@ export function CronSection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Max Delay</Label>
|
||||
<InfoLabel tip="Maximum delay cap for exponential backoff. Retries won't wait longer than this. Go duration format.">Max Delay</InfoLabel>
|
||||
<Input
|
||||
value={draft.retry_max_delay ?? ""}
|
||||
onChange={(e) => update({ retry_max_delay: e.target.value })}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useState, useEffect } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { TagInput } from "@/components/shared/tag-input";
|
||||
|
||||
interface GatewayData {
|
||||
host?: string;
|
||||
@@ -64,7 +65,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Host</Label>
|
||||
<InfoLabel tip="IP address to bind the server. Use 0.0.0.0 to accept connections from any interface.">Host</InfoLabel>
|
||||
<Input
|
||||
value={draft.host ?? ""}
|
||||
onChange={(e) => update({ host: e.target.value })}
|
||||
@@ -72,7 +73,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Port</Label>
|
||||
<InfoLabel tip="TCP port for the WebSocket and HTTP server.">Port</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.port ?? ""}
|
||||
@@ -81,7 +82,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Token</Label>
|
||||
<InfoLabel tip="Bearer token for authenticating WebSocket and API connections. Managed via GOCLAW_GATEWAY_TOKEN env var.">Token</InfoLabel>
|
||||
<Input
|
||||
type="password"
|
||||
value={draft.token ?? ""}
|
||||
@@ -97,26 +98,26 @@ export function GatewaySection({ data, onSave, saving }: Props) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Owner IDs</Label>
|
||||
<Input
|
||||
value={(draft.owner_ids ?? []).join(", ")}
|
||||
onChange={(e) => update({ owner_ids: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Comma-separated sender IDs"
|
||||
<InfoLabel tip="Sender IDs with admin privileges. These users bypass rate limits and can manage configuration.">Owner IDs</InfoLabel>
|
||||
<TagInput
|
||||
value={draft.owner_ids ?? []}
|
||||
onChange={(v) => update({ owner_ids: v })}
|
||||
placeholder="Type ID and press Enter..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Allowed Origins</Label>
|
||||
<Input
|
||||
value={(draft.allowed_origins ?? []).join(", ")}
|
||||
onChange={(e) => update({ allowed_origins: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Empty = allow all"
|
||||
<InfoLabel tip="CORS allowed origins for the HTTP API. Empty means all origins are allowed.">Allowed Origins</InfoLabel>
|
||||
<TagInput
|
||||
value={draft.allowed_origins ?? []}
|
||||
onChange={(v) => update({ allowed_origins: v })}
|
||||
placeholder="Type origin and press Enter..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Max Message Chars</Label>
|
||||
<InfoLabel tip="Maximum characters per inbound message. Messages exceeding this are rejected.">Max Message Chars</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.max_message_chars ?? ""}
|
||||
@@ -125,7 +126,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Rate Limit (RPM)</Label>
|
||||
<InfoLabel tip="Requests per minute per sender. 0 = no limit. Prevents abuse from individual users.">Rate Limit (RPM)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.rate_limit_rpm ?? ""}
|
||||
@@ -135,7 +136,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Inbound Debounce (ms)</Label>
|
||||
<InfoLabel tip="Delay in ms before processing messages. Groups rapid messages into one request. -1 = disabled.">Inbound Debounce (ms)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.inbound_debounce_ms ?? ""}
|
||||
@@ -145,7 +146,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Injection Action</Label>
|
||||
<InfoLabel tip="Action when prompt injection is detected. Off = disabled, Log = log only, Warn = add warning to prompt, Block = reject.">Injection Action</InfoLabel>
|
||||
<Select value={draft.injection_action ?? "warn"} onValueChange={(v) => update({ injection_action: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useState, useEffect } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
|
||||
interface SessionsData {
|
||||
storage?: string;
|
||||
@@ -46,7 +46,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Storage Directory</Label>
|
||||
<InfoLabel tip="Directory path where session data is persisted on disk (standalone mode).">Storage Directory</InfoLabel>
|
||||
<Input
|
||||
value={draft.storage ?? ""}
|
||||
onChange={(e) => update({ storage: e.target.value })}
|
||||
@@ -54,7 +54,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Main Key</Label>
|
||||
<InfoLabel tip="Default session key name. Used as the main conversation thread identifier.">Main Key</InfoLabel>
|
||||
<Input
|
||||
value={draft.main_key ?? ""}
|
||||
onChange={(e) => update({ main_key: e.target.value })}
|
||||
@@ -65,7 +65,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Scope</Label>
|
||||
<InfoLabel tip="Session isolation level. Per Sender = each user gets their own session. Global = all users share one session.">Scope</InfoLabel>
|
||||
<Select value={draft.scope ?? "per-sender"} onValueChange={(v) => update({ scope: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -77,7 +77,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>DM Scope</Label>
|
||||
<InfoLabel tip="How DM sessions are scoped. Controls session isolation for direct messages across different channels and accounts.">DM Scope</InfoLabel>
|
||||
<Select value={draft.dm_scope ?? "per-channel-peer"} onValueChange={(v) => update({ dm_scope: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useState, useEffect } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { KeyValueEditor } from "@/components/shared/key-value-editor";
|
||||
|
||||
interface TelemetryData {
|
||||
enabled?: boolean;
|
||||
@@ -27,12 +27,12 @@ interface Props {
|
||||
|
||||
export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
const [draft, setDraft] = useState<TelemetryData>(data ?? DEFAULT);
|
||||
const [headersText, setHeadersText] = useState("");
|
||||
const [headers, setHeaders] = useState<Record<string, string>>({});
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(data ?? DEFAULT);
|
||||
setHeadersText(data?.headers ? JSON.stringify(data.headers, null, 2) : "{}");
|
||||
setHeaders(data?.headers ?? {});
|
||||
setDirty(false);
|
||||
}, [data]);
|
||||
|
||||
@@ -42,13 +42,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
let headers: Record<string, string> | undefined;
|
||||
try {
|
||||
headers = JSON.parse(headersText);
|
||||
} catch {
|
||||
headers = draft.headers;
|
||||
}
|
||||
onSave({ ...draft, headers });
|
||||
onSave({ ...draft, headers: Object.keys(headers).length > 0 ? headers : undefined });
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
@@ -61,13 +55,13 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Enabled</Label>
|
||||
<InfoLabel tip="Enable OpenTelemetry export of LLM call traces and spans.">Enabled</InfoLabel>
|
||||
<Switch checked={draft.enabled ?? false} onCheckedChange={(v) => update({ enabled: v })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Endpoint</Label>
|
||||
<InfoLabel tip="OTel collector endpoint address (host:port). E.g. localhost:4317 for gRPC or localhost:4318 for HTTP.">Endpoint</InfoLabel>
|
||||
<Input
|
||||
value={draft.endpoint ?? ""}
|
||||
onChange={(e) => update({ endpoint: e.target.value })}
|
||||
@@ -75,7 +69,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Protocol</Label>
|
||||
<InfoLabel tip="Transport protocol for exporting traces. gRPC is recommended for most setups.">Protocol</InfoLabel>
|
||||
<Select value={draft.protocol ?? "grpc"} onValueChange={(v) => update({ protocol: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -90,7 +84,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Service Name</Label>
|
||||
<InfoLabel tip="Service name reported in trace spans. Useful for distinguishing multiple gateway instances.">Service Name</InfoLabel>
|
||||
<Input
|
||||
value={draft.service_name ?? ""}
|
||||
onChange={(e) => update({ service_name: e.target.value })}
|
||||
@@ -98,21 +92,19 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Insecure (no TLS)</Label>
|
||||
<InfoLabel tip="Disable TLS for the connection to the OTel collector. Use only for local/trusted networks.">Insecure (no TLS)</InfoLabel>
|
||||
<Switch checked={draft.insecure ?? false} onCheckedChange={(v) => update({ insecure: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Headers (JSON)</Label>
|
||||
<Textarea
|
||||
value={headersText}
|
||||
onChange={(e) => {
|
||||
setHeadersText(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
placeholder='{"Authorization": "Bearer ..."}'
|
||||
<InfoLabel tip="Additional HTTP headers sent with each export request. Useful for authentication tokens or routing metadata.">Headers</InfoLabel>
|
||||
<KeyValueEditor
|
||||
value={headers}
|
||||
onChange={(v) => { setHeaders(v); setDirty(true); }}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
addLabel="Add Header"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { ToolNameSelect } from "@/components/shared/tool-name-select";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type ToolsData = Record<string, any>;
|
||||
@@ -60,7 +62,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
|
||||
{/* Profile & Lists */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Profile</Label>
|
||||
<InfoLabel tip="Tool profile preset. Minimal = basic tools, Coding = filesystem + exec, Messaging = channels, Full = all tools enabled.">Profile</InfoLabel>
|
||||
<Select value={draft.profile ?? ""} onValueChange={(v) => update({ profile: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select profile" />
|
||||
@@ -74,7 +76,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Rate Limit (per hour)</Label>
|
||||
<InfoLabel tip="Maximum tool executions per hour across all tools. 0 = no limit.">Rate Limit (per hour)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.rate_limit_per_hour ?? ""}
|
||||
@@ -85,37 +87,36 @@ export function ToolsSection({ data, onSave, saving }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Allow</Label>
|
||||
<Input
|
||||
value={(draft.allow ?? []).join(", ")}
|
||||
onChange={(e) => update({ allow: e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Comma-separated tool names"
|
||||
<InfoLabel tip="Explicit whitelist of tool names. Only these tools will be available (overrides profile).">Allow</InfoLabel>
|
||||
<ToolNameSelect
|
||||
value={draft.allow ?? []}
|
||||
onChange={(v) => { update({ allow: v }); }}
|
||||
placeholder="Select tools to allow..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Deny</Label>
|
||||
<Input
|
||||
value={(draft.deny ?? []).join(", ")}
|
||||
onChange={(e) => update({ deny: e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Comma-separated"
|
||||
<InfoLabel tip="Blacklist of tool names. These tools are disabled regardless of profile or allow list.">Deny</InfoLabel>
|
||||
<ToolNameSelect
|
||||
value={draft.deny ?? []}
|
||||
onChange={(v) => { update({ deny: v }); }}
|
||||
placeholder="Select tools to deny..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Also Allow</Label>
|
||||
<Input
|
||||
value={(draft.alsoAllow ?? []).join(", ")}
|
||||
onChange={(e) => update({ alsoAllow: e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Additive list"
|
||||
<InfoLabel tip="Additional tools to enable on top of the profile. Additive to the profile's default set.">Also Allow</InfoLabel>
|
||||
<ToolNameSelect
|
||||
value={draft.alsoAllow ?? []}
|
||||
onChange={(v) => { update({ alsoAllow: v }); }}
|
||||
placeholder="Select additional tools..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Scrub Credentials</Label>
|
||||
<p className="text-xs text-muted-foreground">Auto-redact API keys in tool output</p>
|
||||
<InfoLabel tip="Automatically redact API keys, tokens, and other credentials from tool output before sending to the LLM.">Scrub Credentials</InfoLabel>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.scrub_credentials !== false}
|
||||
@@ -130,7 +131,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
|
||||
<h4 className="mb-3 text-sm font-medium">Exec Approval</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Security</Label>
|
||||
<InfoLabel tip="Security level for shell command execution. Deny = no exec, Allowlist = only matching patterns, Full = allow all commands.">Security</InfoLabel>
|
||||
<Select value={exec.security ?? "full"} onValueChange={(v) => updateNested("execApproval", { security: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -143,7 +144,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Ask Mode</Label>
|
||||
<InfoLabel tip="When to ask the user for approval before executing a command. Off = never ask, On Miss = ask if not in allowlist, Always = always ask.">Ask Mode</InfoLabel>
|
||||
<Select value={exec.ask ?? "off"} onValueChange={(v) => updateNested("execApproval", { ask: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combobox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-deferred-loading.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agent-presets.ts","./src/pages/agents/agents-page.tsx","./src/pages/agents/summoning-modal.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-links-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/quality-gates-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/agent-detail/file-sections/file-editor.tsx","./src/pages/agents/agent-detail/file-sections/file-sidebar.tsx","./src/pages/agents/agent-detail/file-sections/file-utils.ts","./src/pages/agents/agent-detail/file-sections/index.ts","./src/pages/agents/agent-detail/file-sections/open-agent-empty-state.tsx","./src/pages/agents/agent-detail/file-sections/regenerate-dialog.tsx","./src/pages/agents/agent-detail/general-sections/identity-section.tsx","./src/pages/agents/agent-detail/general-sections/index.ts","./src/pages/agents/agent-detail/general-sections/llm-config-section.tsx","./src/pages/agents/agent-detail/general-sections/workspace-section.tsx","./src/pages/agents/agent-detail/link-sections/index.ts","./src/pages/agents/agent-detail/link-sections/link-create-form.tsx","./src/pages/agents/agent-detail/link-sections/link-edit-dialog.tsx","./src/pages/agents/agent-detail/link-sections/link-list.tsx","./src/pages/agents/agent-detail/link-sections/link-utils.ts","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agent-links.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./src/pages/approvals/hooks/use-approvals.ts","./src/pages/builtin-tools/builtin-tool-settings-dialog.tsx","./src/pages/builtin-tools/builtin-tools-page.tsx","./src/pages/builtin-tools/hooks/use-builtin-tools.ts","./src/pages/channLine truncated
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/info-label.tsx","./src/components/shared/key-value-editor.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/provider-model-select.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/shared/tag-input.tsx","./src/components/shared/tool-name-select.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combobox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-deferred-loading.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agent-presets.ts","./src/pages/agents/agents-page.tsx","./src/pages/agents/summoning-modal.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-links-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/quality-gates-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/agent-detail/file-sections/file-editor.tsx","./src/pages/agents/agent-detail/file-sections/file-sidebar.tsx","./src/pages/agents/agent-detail/file-sections/file-utils.ts","./src/pages/agents/agent-detail/file-sections/index.ts","./src/pages/agents/agent-detail/file-sections/open-agent-empty-state.tsx","./src/pages/agents/agent-detail/file-sections/regenerate-dialog.tsx","./src/pages/agents/agent-detail/general-sections/identity-section.tsx","./src/pages/agents/agent-detail/general-sections/index.ts","./src/pages/agents/agent-detail/general-sections/llm-config-section.tsx","./src/pages/agents/agent-detail/general-sections/workspace-section.tsx","./src/pages/agents/agent-detail/link-sections/index.ts","./src/pages/agents/agent-detail/link-sections/link-create-form.tsx","./src/pages/agents/agent-detail/link-sections/link-edit-dialog.tsx","./src/pages/agents/agent-detail/link-sections/link-list.tsx","./src/pages/agents/agent-detail/link-sections/link-utils.ts","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agent-links.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./srLine truncated
|
||||
Reference in new issue
Block a user