AI Settings Panel
AI Settings Panel é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
HextaUI
MIT
other
O que é
AI Settings Panel é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Não precisa de pacotes além do que o projeto já tem
Licença
Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
AI Settings Panel
"use client";
import { Loader2, RotateCcw, Save, Settings, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/registry/new-york/ui/alert-dialog";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york/ui/card";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@/registry/new-york/ui/field";
import {
InputGroup,
InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york/ui/select";
import { Separator } from "@/registry/new-york/ui/separator";
import { Slider } from "@/registry/new-york/ui/slider";
import { Textarea } from "@/registry/new-york/ui/textarea";
export interface AISettings {
temperature?: number;
maxTokens?: number;
topP?: number;
topK?: number;
frequencyPenalty?: number;
presencePenalty?: number;
systemPrompt?: string;
model?: string;
}
export interface AISettingsPreset {
id: string;
name: string;
description?: string;
settings: AISettings;
}
export interface AISettingsPanelProps {
settings?: AISettings;
presets?: AISettingsPreset[];
onSettingsChange?: (settings: AISettings) => void;
onSave?: (settings: AISettings) => Promise<void>;
onReset?: () => void;
onLoadPreset?: (presetId: string) => void;
onSavePreset?: (name: string, settings: AISettings) => Promise<void>;
onDeletePreset?: (presetId: string) => Promise<void>;
className?: string;
showAdvanced?: boolean;
availableModels?: string[];
}
const defaultSettings: AISettings = {
temperature: 0.7,
maxTokens: 2000,
topP: 1.0,
topK: 40,
frequencyPenalty: 0.0,
presencePenalty: 0.0,
systemPrompt: "",
model: "gpt-4",
};
function getTemperatureLabel(temperature: number): string {
if (temperature === 0) return "Deterministic";
if (temperature < 0.5) return "Focused";
if (temperature < 1.0) return "Balanced";
return "Creative";
}
interface SettingsHeaderProps {
hasChanges: boolean;
isSaving: boolean;
onReset?: () => void;
onSave?: () => void;
}
function SettingsHeader({
hasChanges,
isSaving,
onReset,
onSave,
}: SettingsHeaderProps) {
return (
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<CardTitle className="wrap-break-word">AI Settings</CardTitle>
<CardDescription className="wrap-break-word">
Configure AI model parameters and behavior
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:shrink-0 sm:flex-row">
{onReset && (
<Button
aria-label="Reset settings to defaults"
className="min-h-[44px] w-full min-w-[44px] sm:min-h-[32px] sm:w-auto sm:min-w-[32px]"
onClick={onReset}
type="button"
variant="outline"
>
<RotateCcw aria-hidden="true" className="size-4" />
<span className="whitespace-nowrap">Reset</span>
</Button>
)}
{onSave && (
<Button
aria-busy={isSaving}
aria-label={isSaving ? "Saving settings" : "Save settings"}
className="min-h-[44px] w-full min-w-[44px] sm:min-h-[32px] sm:w-auto sm:min-w-[32px]"
data-loading={isSaving}
disabled={!hasChanges || isSaving}
onClick={onSave}
type="button"
>
{isSaving ? (
<>
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
<span className="whitespace-nowrap">Saving…</span>
</>
) : (
<>
<Save aria-hidden="true" className="size-4" />
<span className="whitespace-nowrap">Save</span>
</>
)}
</Button>
)}
</div>
</div>
</CardHeader>
);
}
interface PresetItemProps {
preset: AISettingsPreset;
onLoad: (presetId: string) => void;
onDelete?: (presetId: string) => Promise<void>;
isFocused: boolean;
presetRef: (node: HTMLButtonElement | null) => void;
}
function PresetItem({
preset,
onLoad,
onDelete,
isFocused,
presetRef,
}: PresetItemProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onDelete) return;
setShowDeleteDialog(true);
},
[onDelete]
);
const confirmDelete = useCallback(async () => {
if (!onDelete) return;
setIsDeleting(true);
try {
await onDelete(preset.id);
setShowDeleteDialog(false);
} catch (error) {
console.error("Failed to delete preset:", error);
} finally {
setIsDeleting(false);
}
}, [onDelete, preset.id]);
return (
<div role="listitem">
<Badge
aria-label={`Load preset ${preset.name}`}
className={cn(
"min-h-[44px] w-fit min-w-[44px] cursor-pointer gap-2 px-0.5 pl-3.5 sm:min-h-[32px] sm:min-w-[32px]",
isFocused && "ring-2 ring-ring ring-offset-2"
)}
onClick={() => onLoad(preset.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onLoad(preset.id);
}
}}
ref={presetRef}
role="button"
tabIndex={0}
variant="outline"
>
<span>{preset.name}</span>
{onDelete && (
<Button
aria-label={`Delete preset ${preset.name}`}
className="min-h-[44px] min-w-[44px] rounded-full sm:min-h-[32px] sm:min-w-[32px]"
onClick={handleDelete}
size="icon-sm"
type="button"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
)}
</Badge>
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete preset?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{preset.name}"? This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20"
disabled={isDeleting}
onClick={confirmDelete}
>
{isDeleting ? (
<>
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
Deleting…
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
interface PresetSelectorProps {
presets: AISettingsPreset[];
onLoadPreset: (presetId: string) => void;
onDeletePreset?: (presetId: string) => Promise<void>;
}
function PresetSelector({
presets,
onLoadPreset,
onDeletePreset,
}: PresetSelectorProps) {
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const presetRefs = useRef<(HTMLButtonElement | null)[]>([]);
const setPresetRef = useCallback(
(index: number) => (node: HTMLButtonElement | null) => {
presetRefs.current[index] = node;
},
[]
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLButtonElement
) {
return;
}
if (presets.length === 0) return;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
const nextIndex =
focusedIndex === null
? 0
: focusedIndex < presets.length - 1
? focusedIndex + 1
: 0;
setFocusedIndex(nextIndex);
presetRefs.current[nextIndex]?.focus();
break;
}
case "ArrowUp": {
e.preventDefault();
const prevIndex =
focusedIndex === null
? presets.length - 1
: focusedIndex > 0
? focusedIndex - 1
: presets.length - 1;
setFocusedIndex(prevIndex);
presetRefs.current[prevIndex]?.focus();
break;
}
case "Home": {
e.preventDefault();
setFocusedIndex(0);
presetRefs.current[0]?.focus();
break;
}
case "End": {
e.preventDefault();
const lastIndex = presets.length - 1;
setFocusedIndex(lastIndex);
presetRefs.current[lastIndex]?.focus();
break;
}
case "Escape": {
if (focusedIndex !== null) {
e.preventDefault();
setFocusedIndex(null);
presetRefs.current[focusedIndex]?.blur();
}
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [presets.length, focusedIndex]);
if (presets.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-2">
<FieldLabel id="presets-label">Presets</FieldLabel>
<div
aria-labelledby="presets-label"
className="flex flex-wrap gap-2"
role="list"
>
{presets.map((preset, index) => (
<PresetItem
isFocused={focusedIndex === index}
key={index}
onDelete={onDeletePreset}
onLoad={onLoadPreset}
preset={preset}
presetRef={setPresetRef(index)}
/>
))}
</div>
</div>
);
}
interface TemperatureFieldProps {
value: number;
onChange: (value: number) => void;
}
function TemperatureField({ value, onChange }: TemperatureFieldProps) {
const label = useMemo(() => getTemperatureLabel(value), [value]);
return (
<Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="temperature">
Temperature: {value.toFixed(1)}
</FieldLabel>
<Badge
aria-label={`Temperature mode: ${label}`}
className="text-xs"
variant="secondary"
>
{label}
</Badge>
</div>
<FieldContent>
<Slider
aria-label="Temperature"
id="temperature"
max={2}
min={0}
onValueChange={(values) => onChange(values[0])}
step={0.1}
value={[value]}
/>
<FieldDescription>
Controls randomness. Lower values make responses more focused and
deterministic.
</FieldDescription>
</FieldContent>
</Field>
);
}
interface MaxTokensFieldProps {
value: number;
onChange: (value: number) => void;
}
function MaxTokensField({ value, onChange }: MaxTokensFieldProps) {
return (
<Field>
<FieldLabel htmlFor="max-tokens">Max Tokens</FieldLabel>
<FieldContent>
<InputGroup>
<InputGroupInput
aria-label="Maximum tokens"
id="max-tokens"
max={32_000}
min={1}
onChange={(e) => {
const numValue = Number.parseInt(e.target.value) || 2000;
onChange(numValue);
}}
type="number"
value={value}
/>
</InputGroup>
<FieldDescription>
Maximum number of tokens in the response
</FieldDescription>
</FieldContent>
</Field>
);
}
interface SystemPromptFieldProps {
value: string;
onChange: (value: string) => void;
}
function SystemPromptField({ value, onChange }: SystemPromptFieldProps) {
return (
<Field>
<FieldLabel htmlFor="system-prompt">System Prompt</FieldLabel>
<FieldContent>
<Textarea
aria-label="System prompt"
id="system-prompt"
onChange={(e) => onChange(e.target.value)}
placeholder="Enter system prompt to guide AI behavior…"
rows={4}
value={value}
/>
<FieldDescription>
Instructions that guide the AI's behavior and responses
</FieldDescription>
</FieldContent>
</Field>
);
}
interface ModelSelectFieldProps {
value: string;
availableModels: string[];
onChange: (value: string) => void;
}
function ModelSelectField({
value,
availableModels,
onChange,
}: ModelSelectFieldProps) {
if (availableModels.length === 0) {
return null;
}
return (
<Field>
<FieldLabel htmlFor="model">Model</FieldLabel>
<FieldContent>
<div className="[&_button]:min-h-[44px] [&_button]:sm:min-h-[32px]">
<Select
onValueChange={onChange}
value={value || availableModels[0] || ""}
>
<SelectTrigger id="model">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FieldContent>
</Field>
);
}
interface AdvancedSettingsSectionProps {
settings: AISettings;
onSettingChange: <K extends keyof AISettings>(
key: K,
value: AISettings[K]
) => void;
}
function AdvancedSettingsSection({
settings,
onSettingChange,
}: AdvancedSettingsSectionProps) {
return (
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
<Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="top-p">
Top P: {settings.topP?.toFixed(2) || "1.00"}
</FieldLabel>
</div>
<FieldContent>
<Slider
aria-label="Top P"
id="top-p"
max={1}
min={0}
onValueChange={(values) => onSettingChange("topP", values[0])}
step={0.01}
value={[settings.topP || 1.0]}
/>
<FieldDescription>
Nucleus sampling: considers tokens with top-p probability mass
</FieldDescription>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="top-k">Top K: {settings.topK || "40"}</FieldLabel>
<FieldContent>
<Slider
aria-label="Top K"
id="top-k"
max={100}
min={1}
onValueChange={(values) => onSettingChange("topK", values[0])}
step={1}
value={[settings.topK || 40]}
/>
<FieldDescription>
Limits sampling to top K most likely tokens
</FieldDescription>
</FieldContent>
</Field>
<Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="frequency-penalty">
Frequency Penalty: {settings.frequencyPenalty?.toFixed(1) || "0.0"}
</FieldLabel>
</div>
<FieldContent>
<Slider
aria-label="Frequency penalty"
id="frequency-penalty"
max={2}
min={-2}
onValueChange={(values) =>
onSettingChange("frequencyPenalty", values[0])
}
step={0.1}
value={[settings.frequencyPenalty || 0.0]}
/>
<FieldDescription>
Reduces likelihood of repeating tokens (positive) or increases it
(negative)
</FieldDescription>
</FieldContent>
</Field>
<Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="presence-penalty">
Presence Penalty: {settings.presencePenalty?.toFixed(1) || "0.0"}
</FieldLabel>
</div>
<FieldContent>
<Slider
aria-label="Presence penalty"
id="presence-penalty"
max={2}
min={-2}
onValueChange={(values) =>
onSettingChange("presencePenalty", values[0])
}
step={0.1}
value={[settings.presencePenalty || 0.0]}
/>
<FieldDescription>
Reduces likelihood of talking about new topics (positive) or
increases it (negative)
</FieldDescription>
</FieldContent>
</Field>
</div>
);
}
interface AdvancedSettingsToggleProps {
isOpen: boolean;
onToggle: () => void;
}
function AdvancedSettingsToggle({
isOpen,
onToggle,
}: AdvancedSettingsToggleProps) {
return (
<Button
aria-expanded={isOpen}
aria-label={isOpen ? "Hide advanced settings" : "Show advanced settings"}
className="min-h-[44px] w-full min-w-[44px] sm:min-h-[32px] sm:w-auto sm:min-w-[32px]"
onClick={onToggle}
type="button"
variant="ghost"
>
<Settings aria-hidden="true" className="size-4" />
{isOpen ? "Hide" : "Show"} Advanced Settings
</Button>
);
}
export default function AISettingsPanel({
settings = defaultSettings,
presets = [],
onSettingsChange,
onSave,
onReset,
onLoadPreset,
onDeletePreset,
className,
showAdvanced = false,
availableModels = [
"gpt-4",
"gpt-3.5-turbo",
"claude-3-opus",
"claude-3-sonnet",
],
}: AISettingsPanelProps) {
const [localSettings, setLocalSettings] = useState<AISettings>(settings);
const [isSaving, setIsSaving] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] =
useState(showAdvanced);
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
const updateSetting = useCallback(
<K extends keyof AISettings>(key: K, value: AISettings[K]) => {
const newSettings = { ...localSettings, [key]: value };
setLocalSettings(newSettings);
onSettingsChange?.(newSettings);
},
[localSettings, onSettingsChange]
);
const handleSave = useCallback(async () => {
if (!onSave) return;
setIsSaving(true);
try {
await onSave(localSettings);
} catch (error) {
console.error("Failed to save settings:", error);
} finally {
setIsSaving(false);
}
}, [onSave, localSettings]);
const handleReset = useCallback(() => {
setLocalSettings(defaultSettings);
onReset?.();
onSettingsChange?.(defaultSettings);
}, [onReset, onSettingsChange]);
const toggleAdvancedSettings = useCallback(() => {
setShowAdvancedSettings((prev) => !prev);
}, []);
const hasChanges = useMemo(
() => JSON.stringify(localSettings) !== JSON.stringify(settings),
[localSettings, settings]
);
const handleLoadPreset = useCallback(
(presetId: string) => {
const preset = presets.find((p) => p.id === presetId);
if (preset) {
setLocalSettings(preset.settings);
onLoadPreset?.(presetId);
onSettingsChange?.(preset.settings);
}
},
[presets, onLoadPreset, onSettingsChange]
);
return (
<Card className={cn("w-full shadow-xs", className)}>
<SettingsHeader
hasChanges={hasChanges}
isSaving={isSaving}
onReset={onReset ? handleReset : undefined}
onSave={onSave ? handleSave : undefined}
/>
<CardContent>
<div className="flex flex-col gap-6">
{presets.length > 0 && onLoadPreset && (
<>
<PresetSelector
onDeletePreset={onDeletePreset}
onLoadPreset={handleLoadPreset}
presets={presets}
/>
<Separator />
</>
)}
<div className="flex flex-col gap-4">
<ModelSelectField
availableModels={availableModels}
onChange={(value) => updateSetting("model", value)}
value={localSettings.model ?? availableModels[0] ?? ""}
/>
<TemperatureField
onChange={(value) => updateSetting("temperature", value)}
value={localSettings.temperature || 0.7}
/>
<MaxTokensField
onChange={(value) => updateSetting("maxTokens", value)}
value={localSettings.maxTokens || 2000}
/>
<SystemPromptField
onChange={(value) => updateSetting("systemPrompt", value)}
value={localSettings.systemPrompt || ""}
/>
</div>
<Separator />
<div className="flex flex-col gap-4">
<AdvancedSettingsToggle
isOpen={showAdvancedSettings}
onToggle={toggleAdvancedSettings}
/>
{showAdvancedSettings && (
<AdvancedSettingsSection
onSettingChange={updateSetting}
settings={localSettings}
/>
)}
</div>
</div>
</CardContent>
</Card>
);
}