diff --git a/packages/client/src/components/agent-creator.tsx b/packages/client/src/components/agent-creator.tsx index c85831e3f06..223ea72d470 100644 --- a/packages/client/src/components/agent-creator.tsx +++ b/packages/client/src/components/agent-creator.tsx @@ -7,25 +7,32 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import AvatarPanel from './avatar-panel'; import PluginsPanel from './plugins-panel'; -import SecretPanel from './secret-panel'; +import { SecretPanel } from './secret-panel'; +import { useAgentUpdate } from '@/hooks/use-agent-update'; -const defaultCharacter = { +// Define a partial agent for initialization +const defaultCharacter: Partial = { name: '', username: '', system: '', bio: [] as string[], topics: [] as string[], adjectives: [] as string[], -} as Agent; + plugins: ['@elizaos/plugin-sql', '@elizaos/plugin-local-ai'], + settings: { secrets: {} }, +}; export default function AgentCreator() { const navigate = useNavigate(); const { toast } = useToast(); const queryClient = useQueryClient(); - const [characterValue, setCharacterValue] = useState({ + const [initialCharacter] = useState>({ ...defaultCharacter, }); + // Use agent update hook for proper handling of nested fields + const agentState = useAgentUpdate(initialCharacter as Agent); + const ensureRequiredFields = (character: Agent): Agent => { return { ...character, @@ -40,6 +47,7 @@ export default function AgentCreator() { chat: character.style?.chat ?? [], post: character.style?.post ?? [], }, + settings: character.settings ?? { secrets: {} }, }; }; @@ -70,12 +78,12 @@ export default function AgentCreator() { return ( setCharacterValue(defaultCharacter)} + onReset={() => agentState.reset()} onDelete={() => { navigate('/'); }} @@ -84,19 +92,24 @@ export default function AgentCreator() { { name: 'Plugins', component: ( - + ), }, { name: 'Secret', component: ( - + { + agentState.updateSettings(updatedAgent.settings); + }} + /> ), }, { name: 'Avatar', component: ( - + ), }, ]} diff --git a/packages/client/src/components/agent-settings.tsx b/packages/client/src/components/agent-settings.tsx index 829a68a930c..5ad59ca362c 100644 --- a/packages/client/src/components/agent-settings.tsx +++ b/packages/client/src/components/agent-settings.tsx @@ -1,35 +1,53 @@ import CharacterForm from '@/components/character-form'; import { useToast } from '@/hooks/use-toast'; +import { useAgentUpdate } from '@/hooks/use-agent-update'; import { apiClient } from '@/lib/api'; import type { Agent, UUID } from '@elizaos/core'; import { useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import AvatarPanel from './avatar-panel'; import PluginsPanel from './plugins-panel'; -import SecretPanel from './secret-panel'; +import { SecretPanel } from './secret-panel'; export default function AgentSettings({ agent, agentId }: { agent: Agent; agentId: UUID }) { const { toast } = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const [characterValue, setCharacterValue] = useState(agent); - const handleSubmit = async (updatedAgent: Agent) => { + // Use our enhanced agent update hook for more intelligent handling of JSONb fields + const agentState = useAgentUpdate(agent); + + // Log whenever agent state changes + useEffect(() => {}, [agentState.agent]); + + const handleSubmit = async () => { try { - // Call the API to update the agent's character if (!agentId) { throw new Error('Agent ID is missing'); } - // Make sure plugins are preserved - const mergedAgent = { - ...updatedAgent, - plugins: characterValue.plugins, // Preserve the plugins from our local state + // Get only the fields that have changed + const changedFields = agentState.getChangedFields(); + + // No need to send update if nothing changed + if (Object.keys(changedFields).length === 0) { + toast({ + title: 'No Changes', + description: 'No changes were made to the agent', + }); + navigate('/'); + return; + } + + // Always include the ID + const partialUpdate = { + id: agentId, + ...changedFields, }; - // Send the character update request to the agent endpoint - await apiClient.updateAgent(agentId, mergedAgent); + // Send the partial update + await apiClient.updateAgent(agentId, partialUpdate as Agent); // Invalidate both the agent query and the agents list queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); @@ -51,43 +69,66 @@ export default function AgentSettings({ agent, agentId }: { agent: Agent; agentI } }; - const handleDelete = async (agent: Agent) => { + const handleDelete = async () => { try { - await apiClient.deleteAgent(agent.id as UUID); + await apiClient.deleteAgent(agentId); queryClient.invalidateQueries({ queryKey: ['agents'] }); navigate('/'); + + toast({ + title: 'Success', + description: 'Agent deleted successfully', + }); } catch (error) { - console.error('Error deleting agent:', error); + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to delete agent', + variant: 'destructive', + }); } }; return ( setCharacterValue(agent)} - onDelete={() => handleDelete(agent)} + onReset={agentState.reset} + onDelete={handleDelete} isAgent={true} customComponents={[ { name: 'Plugins', component: ( - + ), }, { name: 'Secret', component: ( - + { + if (updatedAgent.settings && updatedAgent.settings.secrets) { + // Create a new settings object with the updated secrets + const updatedSettings = { + ...agentState.agent.settings, + secrets: updatedAgent.settings.secrets, + }; + + // Use updateSettings to properly handle the secrets + agentState.updateSettings(updatedSettings); + } + }} + /> ), }, { name: 'Avatar', component: ( - + ), }, ]} diff --git a/packages/client/src/components/avatar-panel.tsx b/packages/client/src/components/avatar-panel.tsx index 178f6d140e1..e20f8285a4d 100644 --- a/packages/client/src/components/avatar-panel.tsx +++ b/packages/client/src/components/avatar-panel.tsx @@ -1,39 +1,67 @@ import { Button } from '@/components/ui/button'; import type { Agent } from '@elizaos/core'; import { Image as ImageIcon, Upload, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { compressImage } from '@/lib/utils'; interface AvatarPanelProps { characterValue: Agent; - setCharacterValue: (value: (prev: Agent) => Agent) => void; + setCharacterValue: { + updateAvatar?: (avatarUrl: string) => void; + updateSetting?: (path: string, value: T) => void; + updateField?: (path: string, value: T) => void; + [key: string]: any; + }; } export default function AvatarPanel({ characterValue, setCharacterValue }: AvatarPanelProps) { const [avatar, setAvatar] = useState(characterValue?.settings?.avatar || null); + const [hasChanged, setHasChanged] = useState(false); const fileInputRef = useRef(null); + // Reset the change flag when component initializes or character changes + useEffect(() => { + setAvatar(characterValue?.settings?.avatar || null); + setHasChanged(false); + }, [characterValue.id]); + const handleFileUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { try { const compressedImage = await compressImage(file); setAvatar(compressedImage); + setHasChanged(true); + + // Only update when there's a real change + updateCharacterAvatar(compressedImage); } catch (error) { console.error('Error compressing image:', error); } } }; - useEffect(() => { - setCharacterValue((prev) => ({ - ...prev, - settings: { - ...prev.settings, - avatar: avatar, - }, - })); - }, [avatar, setCharacterValue]); + const handleRemoveAvatar = () => { + if (avatar) { + setAvatar(null); + setHasChanged(true); + updateCharacterAvatar(''); + } + }; + + // Centralized update function to avoid code duplication + const updateCharacterAvatar = (avatarUrl: string) => { + if (setCharacterValue.updateAvatar) { + // Use the specialized method for avatar updates when available + setCharacterValue.updateAvatar(avatarUrl); + } else if (setCharacterValue.updateSetting) { + // Use updateSetting as fallback + setCharacterValue.updateSetting('avatar', avatarUrl); + } else if (setCharacterValue.updateField) { + // Last resort - use the generic field update + setCharacterValue.updateField('settings.avatar', avatarUrl); + } + }; return (
@@ -45,7 +73,7 @@ export default function AvatarPanel({ characterValue, setCharacterValue }: Avata Character Avatar + + {hasChanged &&

Avatar has been updated

}
); diff --git a/packages/client/src/components/character-form.tsx b/packages/client/src/components/character-form.tsx index 8c69ff8fa17..a443b0b3fa0 100644 --- a/packages/client/src/components/character-form.tsx +++ b/packages/client/src/components/character-form.tsx @@ -136,7 +136,13 @@ export type CharacterFormProps = { isAgent?: boolean; customComponents?: customComponent[]; characterValue: Agent; - setCharacterValue: (value: (prev: Agent) => Agent) => void; + setCharacterValue: { + updateField: (path: string, value: T) => void; + addArrayItem?: (path: string, item: T) => void; + removeArrayItem?: (path: string, index: number) => void; + updateSetting?: (path: string, value: any) => void; + [key: string]: any; + }; }; export default function CharacterForm({ @@ -157,49 +163,45 @@ export default function CharacterForm({ const { name, value, type } = e.target; const checked = (e.target as HTMLInputElement).checked; - if (name.includes('.')) { - const parts = name.split('.'); - setCharacterValue((prev) => { - const newValue = { ...prev }; - let current: Record = newValue; - - for (let i = 0; i < parts.length - 1; i++) { - if (!current[parts[i]]) { - current[parts[i]] = {}; - } - current = current[parts[i]]; - } + if (type === 'checkbox') { + setCharacterValue.updateField(name, checked); + } else if (name.startsWith('settings.')) { + // Handle nested settings fields like settings.voice.model + const path = name.substring(9); // Remove 'settings.' prefix - current[parts[parts.length - 1]] = type === 'checkbox' ? checked : value; - return newValue; - }); + if (setCharacterValue.updateSetting) { + // Use the specialized method if available + setCharacterValue.updateSetting(path, value); + } else { + // Fall back to generic updateField + setCharacterValue.updateField(name, value); + } } else { - setCharacterValue((prev) => ({ - ...prev, - [name]: type === 'checkbox' ? checked : value, - })); + setCharacterValue.updateField(name, value); } }; const updateArray = (path: string, newData: string[]) => { - setCharacterValue((prev) => { - const newValue = { ...prev }; - const keys = path.split('.'); - let current: any = newValue; - - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; + // If the path is a simple field name + if (!path.includes('.')) { + setCharacterValue.updateField(path, newData); + return; + } - if (!current[key] || typeof current[key] !== 'object') { - current[key] = {}; // Ensure path exists - } - current = current[key]; + // Handle nested paths (e.g. style.all) + const parts = path.split('.'); + if (parts.length === 2 && parts[0] === 'style') { + // For style arrays, use the setStyleArray method if available + if (setCharacterValue.setStyleArray) { + setCharacterValue.setStyleArray(parts[1] as 'all' | 'chat' | 'post', newData); + } else { + setCharacterValue.updateField(path, newData); } + return; + } - current[keys[keys.length - 1]] = newData; // Update array - - return newValue; - }); + // Default case - just update the field + setCharacterValue.updateField(path, newData); }; const ensureAvatarSize = async (char: Agent): Promise => { @@ -225,7 +227,7 @@ export default function CharacterForm({ return char; }; - const handleSubmit = async (e: FormEvent) => { + const handleFormSubmit = async (e: FormEvent) => { e.preventDefault(); setIsSubmitting(true); @@ -296,7 +298,7 @@ export default function CharacterForm({ -
+ { onReset?.(); - // setCharacterValue(character) }} > Reset Changes diff --git a/packages/client/src/components/plugins-panel.tsx b/packages/client/src/components/plugins-panel.tsx index 34eca9c92ee..d914a40533d 100644 --- a/packages/client/src/components/plugins-panel.tsx +++ b/packages/client/src/components/plugins-panel.tsx @@ -8,23 +8,25 @@ import { import { Input } from '@/components/ui/input'; import { usePlugins } from '@/hooks/use-plugins'; import type { Agent } from '@elizaos/core'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { Button } from './ui/button'; interface PluginsPanelProps { characterValue: Agent; - setCharacterValue: (value: (prev: Agent) => Agent) => void; + setCharacterValue: { + addPlugin?: (pluginId: string) => void; + removePlugin?: (index: number) => void; + setPlugins?: (plugins: string[]) => void; + updateField?: (path: string, value: T) => void; + [key: string]: any; + }; } export default function PluginsPanel({ characterValue, setCharacterValue }: PluginsPanelProps) { const { data: plugins, error } = usePlugins(); const [searchQuery, setSearchQuery] = useState(''); const [isDialogOpen, setIsDialogOpen] = useState(false); - - const pluginNames = useMemo(() => { - if (!plugins) return []; - return Object.keys(plugins).map((name) => name.replace(/^@elizaos-plugins\//, '@elizaos/')); - }, [plugins]); + const [hasChanged, setHasChanged] = useState(false); // Ensure we always have arrays and normalize plugin names const safeCharacterPlugins = useMemo(() => { @@ -32,24 +34,51 @@ export default function PluginsPanel({ characterValue, setCharacterValue }: Plug return characterValue.plugins; }, [characterValue?.plugins]); + // Get plugin names from available plugins + const pluginNames = useMemo(() => { + if (!plugins) return []; + return Object.keys(plugins).map((name) => name.replace(/^@elizaos-plugins\//, '@elizaos/')); + }, [plugins]); + + // Reset change tracking when character changes + useEffect(() => { + setHasChanged(false); + }, [characterValue.id]); + const filteredPlugins = useMemo(() => { return pluginNames .filter((plugin) => !safeCharacterPlugins.includes(plugin)) .filter((plugin) => plugin.toLowerCase().includes(searchQuery.toLowerCase())); }, [pluginNames, safeCharacterPlugins, searchQuery]); - const handlePluginToggle = (plugin: string) => { - setCharacterValue((prev) => { - const currentPlugins = Array.isArray(prev.plugins) ? prev.plugins : []; - const newPlugins = currentPlugins.includes(plugin) - ? currentPlugins.filter((p) => p !== plugin) - : [...currentPlugins, plugin]; + const handlePluginAdd = (plugin: string) => { + if (safeCharacterPlugins.includes(plugin)) return; + + setHasChanged(true); - return { - ...prev, - plugins: newPlugins, - }; - }); + if (setCharacterValue.addPlugin) { + setCharacterValue.addPlugin(plugin); + } else if (setCharacterValue.updateField) { + const currentPlugins = Array.isArray(characterValue.plugins) + ? [...characterValue.plugins] + : []; + setCharacterValue.updateField('plugins', [...currentPlugins, plugin]); + } + }; + + const handlePluginRemove = (plugin: string) => { + const index = safeCharacterPlugins.indexOf(plugin); + if (index !== -1) { + setHasChanged(true); + + if (setCharacterValue.removePlugin) { + setCharacterValue.removePlugin(index); + } else if (setCharacterValue.updateField) { + const newPlugins = [...safeCharacterPlugins]; + newPlugins.splice(index, 1); + setCharacterValue.updateField('plugins', newPlugins); + } + } }; return ( @@ -72,7 +101,7 @@ export default function PluginsPanel({ characterValue, setCharacterValue }: Plug size="sm" key={plugin} className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary hover:bg-primary/20 h-auto" - onClick={() => handlePluginToggle(plugin)} + onClick={() => handlePluginRemove(plugin)} > {plugin} × @@ -110,7 +139,7 @@ export default function PluginsPanel({ characterValue, setCharacterValue }: Plug variant="ghost" className="w-full justify-start font-normal" onClick={() => { - handlePluginToggle(plugin); + handlePluginAdd(plugin); setSearchQuery(''); setIsDialogOpen(false); }} @@ -124,6 +153,9 @@ export default function PluginsPanel({ characterValue, setCharacterValue }: Plug + {hasChanged && ( +

Plugins configuration has been updated

+ )} )} diff --git a/packages/client/src/components/secret-panel.tsx b/packages/client/src/components/secret-panel.tsx index 29d58a35172..fe3aacf60e6 100644 --- a/packages/client/src/components/secret-panel.tsx +++ b/packages/client/src/components/secret-panel.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { usePartialUpdate } from '@/hooks/use-partial-update'; import type { Agent } from '@elizaos/core'; import { Check, CloudUpload, Eye, EyeOff, MoreVertical, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; @@ -15,6 +16,9 @@ interface SecretPanelProps { } export default function EnvSettingsPanel({ characterValue, setCharacterValue }: SecretPanelProps) { + // Use our new hook to properly handle updates to nested JSONb fields + const [agentState, updateField] = usePartialUpdate(characterValue); + const [envs, setEnvs] = useState( Object.entries(characterValue?.settings?.secrets || {}).map(([name, value]) => ({ name, @@ -28,6 +32,13 @@ export default function EnvSettingsPanel({ characterValue, setCharacterValue }: const [openIndex, setOpenIndex] = useState(null); const [editingIndex, setEditingIndex] = useState(null); const [editedValue, setEditedValue] = useState(''); +<<<<<<< HEAD +======= + // Keep track of deleted keys to ensure proper removal + const [deletedKeys, setDeletedKeys] = useState([]); + // Track if changes are pending to avoid unnecessary updates + const [changesPending, setChangesPending] = useState(false); +>>>>>>> 34f5bfeb8 (chore: optimize logic for update) const dropdownRef = useRef(null); const dropRef = useRef(null); @@ -114,8 +125,12 @@ export default function EnvSettingsPanel({ characterValue, setCharacterValue }: } return updated; }); - setName(''); - setValue(''); + setEnvs([...envs, { name, value, isNew: true }]); + setName(''); + setValue(''); + setChangesPending(true); + } + } }; @@ -128,6 +143,12 @@ export default function EnvSettingsPanel({ characterValue, setCharacterValue }: const saveEdit = (index: number) => { const updatedEnvs = [...envs]; updatedEnvs[index].value = editedValue; + // Only mark as modified if the value actually changed + if (updatedEnvs[index].value !== editedValue) { + updatedEnvs[index].value = editedValue; + updatedEnvs[index].isModified = true; + setChangesPending(true); + } setEnvs(updatedEnvs); setEditingIndex(null); }; @@ -136,6 +157,7 @@ export default function EnvSettingsPanel({ characterValue, setCharacterValue }: setEnvs(envs.filter((_, i) => i !== index)); setOpenIndex(null); setEditingIndex(null); + setChangesPending(true); }; useEffect(() => { @@ -151,15 +173,73 @@ export default function EnvSettingsPanel({ characterValue, setCharacterValue }: }; }, []); + // Update character value when envs change, but only if there are actual changes useEffect(() => { - setCharacterValue((prev) => ({ - ...prev, - settings: { - ...prev.settings, - secrets: Object.fromEntries(envs.map(({ name, value }) => [name, value])), - }, - })); - }, [envs, setCharacterValue]); + if (changesPending) { + // Create a minimal update object to send only the changes + const currentSecrets: Record = {}; + + // Map updated values + envs.forEach(({ name, value }) => { + currentSecrets[name] = value; + }); + + // Add null values for deleted keys to explicitly mark them for removal + deletedKeys.forEach((key) => { + currentSecrets[key] = null; + }); + + // Create a minimal agent object with just the secrets changes + const updatedAgent: Partial = { + settings: { + secrets: currentSecrets, + }, + }; + + // Call the onChange prop with the updated agent + onChange(updatedAgent as Agent); + + // Reset change tracking flags + setEnvs((prevEnvs) => { + return prevEnvs.map((env) => ({ + ...env, + isNew: false, + isModified: false, + })); + }); + + // Clear deletedKeys after changes are applied + setDeletedKeys([]); + setChangesPending(false); + } + }, [envs, onChange, deletedKeys, changesPending]); + + // Sync envs with characterValue when it changes (only if not in middle of edit) + useEffect(() => { + if (characterValue?.settings?.secrets && !changesPending) { + const currentSecretsEntries = Object.entries(characterValue.settings.secrets); + // Only update if the secrets have actually changed (different keys/number of entries) + const currentKeys = currentSecretsEntries + .map(([key]) => key) + .sort() + .join(','); + const envKeys = envs + .map((env) => env.name) + .sort() + .join(','); + + if (currentKeys !== envKeys) { + const newEnvs = currentSecretsEntries.map(([name, value]) => ({ + name, + value: String(value), + isNew: false, + isModified: false, + isDeleted: false, + })); + setEnvs(newEnvs); + } + } + }, [characterValue.settings?.secrets, envs, changesPending]); return (
diff --git a/packages/client/src/hooks/use-agent-update.ts b/packages/client/src/hooks/use-agent-update.ts new file mode 100644 index 00000000000..08018585049 --- /dev/null +++ b/packages/client/src/hooks/use-agent-update.ts @@ -0,0 +1,402 @@ +import { usePartialUpdate } from '@/hooks/use-partial-update'; +import type { Agent } from '@elizaos/core'; +import { useCallback, useRef } from 'react'; + +/** + * A custom hook for handling Agent updates with specific handling for JSONb fields. + * This hook builds on usePartialUpdate but adds Agent-specific convenience methods + * organized by the UI tabs (Basic Info, Content, Style, Plugins, etc.). + * + * @param initialAgent The initial Agent object + * @returns Object with agent state and update methods + */ +export function useAgentUpdate(initialAgent: Agent) { + // Keep reference to the initial state for comparison + const initialAgentRef = useRef(JSON.parse(JSON.stringify(initialAgent))); + + const { + value: agent, + updateField, + addArrayItem, + removeArrayItem, + reset, + updateSettings, + } = usePartialUpdate(initialAgent); + + // ==================== Basic Info Tab ==================== + /** + * Updates a field in the Agent's settings object + * + * @param path Path within settings (e.g., 'voice.model') + * @param value New value + */ + const updateSetting = useCallback( + (path: string, value: T) => { + updateField(`settings.${path}`, value); + }, + [updateField] + ); + + /** + * Updates the entire settings object + * + * @param settings The new settings object + */ + const setSettings = useCallback( + (settings: any) => { + updateSettings(settings); + }, + [updateSettings] + ); + + /** + * Updates the agent's system prompt + * + * @param systemPrompt The new system prompt + */ + const updateSystemPrompt = useCallback( + (systemPrompt: string) => { + updateField('system', systemPrompt); + }, + [updateField] + ); + + // ==================== Secrets Tab ==================== + /** + * Updates a secret in the Agent's settings.secrets object + * + * @param key Secret key + * @param value Secret value + */ + const updateSecret = useCallback( + (key: string, value: string) => { + // Handle nested secrets object properly + const currentSettings = agent.settings || {}; + const currentSecrets = currentSettings.secrets || {}; + + const newSecrets = { + ...currentSecrets, + [key]: value, + }; + + // Update entire settings object for better change detection + updateSettings({ + ...currentSettings, + secrets: newSecrets, + }); + }, + [agent.settings, updateSettings] + ); + + /** + * Removes a secret from the Agent's settings.secrets object + * + * @param key Secret key to remove + */ + const removeSecret = useCallback( + (key: string) => { + // Get the current secrets object + const currentSettings = agent.settings || {}; + const currentSecrets = currentSettings.secrets || {}; + + // Create a new secrets object without the removed key + const newSecrets = { ...currentSecrets }; + delete newSecrets[key]; + + // Update the entire settings object to ensure nested changes are detected + const updatedSettings = { + ...currentSettings, + secrets: newSecrets, + }; + + // Use updateSettings instead of updateField for better change detection + updateSettings(updatedSettings); + }, + [agent.settings, updateSettings] + ); + + // ==================== Content Tab ==================== + /** + * Adds an item to a content array (bio, topics, adjectives) + * + * @param arrayName The name of the array field + * @param item The item to add + */ + const addContentItem = useCallback( + (arrayName: 'bio' | 'topics' | 'adjectives', item: string) => { + addArrayItem(arrayName, item); + }, + [addArrayItem] + ); + + /** + * Removes an item from a content array + * + * @param arrayName The name of the array field + * @param index The index of the item to remove + */ + const removeContentItem = useCallback( + (arrayName: 'bio' | 'topics' | 'adjectives', index: number) => { + removeArrayItem(arrayName, index); + }, + [removeArrayItem] + ); + + /** + * Updates an item in a content array + * + * @param arrayName The name of the array field + * @param index The index of the item to update + * @param value The new value + */ + const updateContentItem = useCallback( + (arrayName: 'bio' | 'topics' | 'adjectives', index: number, value: string) => { + updateField(`${arrayName}.${index}`, value); + }, + [updateField] + ); + + // ==================== Style Tab ==================== + /** + * Adds a style rule to one of the style arrays + * + * @param styleType Type of style ('all', 'chat', 'post') + * @param rule The style rule to add + */ + const addStyleRule = useCallback( + (styleType: 'all' | 'chat' | 'post', rule: string) => { + addArrayItem(`style.${styleType}`, rule); + }, + [addArrayItem] + ); + + /** + * Removes a style rule from one of the style arrays + * + * @param styleType Type of style ('all', 'chat', 'post') + * @param index The index of the rule to remove + */ + const removeStyleRule = useCallback( + (styleType: 'all' | 'chat' | 'post', index: number) => { + removeArrayItem(`style.${styleType}`, index); + }, + [removeArrayItem] + ); + + /** + * Updates a style rule in one of the style arrays + * + * @param styleType Type of style ('all', 'chat', 'post') + * @param index The index of the rule to update + * @param value The new rule value + */ + const updateStyleRule = useCallback( + (styleType: 'all' | 'chat' | 'post', index: number, value: string) => { + updateField(`style.${styleType}.${index}`, value); + }, + [updateField] + ); + + /** + * Sets a complete style array + * + * @param styleType Type of style ('all', 'chat', 'post') + * @param values Array of style values + */ + const setStyleArray = useCallback( + (styleType: 'all' | 'chat' | 'post', values: string[]) => { + updateField(`style.${styleType}`, values); + }, + [updateField] + ); + + // ==================== Plugins Tab ==================== + /** + * Adds a plugin to the agent's plugins array + * + * @param pluginId The plugin ID to add + */ + const addPlugin = useCallback( + (pluginId: string) => { + addArrayItem('plugins', pluginId); + }, + [addArrayItem] + ); + + /** + * Removes a plugin from the agent's plugins array + * + * @param index The index of the plugin to remove + */ + const removePlugin = useCallback( + (index: number) => { + removeArrayItem('plugins', index); + }, + [removeArrayItem] + ); + + /** + * Sets the entire plugins array + * + * @param plugins Array of plugin IDs + */ + const setPlugins = useCallback( + (plugins: string[]) => { + updateField('plugins', plugins); + }, + [updateField] + ); + + // ==================== Avatar Tab ==================== + /** + * Updates the agent's avatar + * + * @param avatarUrl The URL of the avatar image + */ + const updateAvatar = useCallback( + (avatarUrl: string) => { + updateSetting('avatar', avatarUrl); + }, + [updateSetting] + ); + + /** + * Returns an object containing only the fields that have changed + * compared to the initial agent state + */ + const getChangedFields = useCallback(() => { + const changedFields: Partial = {}; + const current = agent; + const initial = initialAgentRef.current; + + // Compare scalar properties + const scalarProps = ['name', 'username', 'system'] as const; + scalarProps.forEach((prop) => { + if (current[prop] !== initial[prop]) { + changedFields[prop] = current[prop]; + } + }); + + if (current.enabled !== initial.enabled) { + changedFields.enabled = current.enabled; + } + + // Compare array properties with type safety + if (JSON.stringify(current.bio) !== JSON.stringify(initial.bio)) { + changedFields.bio = current.bio; + } + + if (JSON.stringify(current.topics) !== JSON.stringify(initial.topics)) { + changedFields.topics = current.topics; + } + + if (JSON.stringify(current.adjectives) !== JSON.stringify(initial.adjectives)) { + changedFields.adjectives = current.adjectives; + } + + if (JSON.stringify(current.plugins) !== JSON.stringify(initial.plugins)) { + changedFields.plugins = current.plugins; + } + + // Compare style object + if (JSON.stringify(current.style) !== JSON.stringify(initial.style)) { + changedFields.style = current.style; + } + + // More granular comparison for settings object + const initialSettings = initial.settings || {}; + const currentSettings = current.settings || {}; + + // Check if any settings changed + if (JSON.stringify(currentSettings) !== JSON.stringify(initialSettings)) { + // Create a partial settings object with only changed fields + changedFields.settings = {}; + + // Check avatar separately + if (currentSettings.avatar !== initialSettings.avatar) { + changedFields.settings.avatar = currentSettings.avatar; + } + + // Check voice settings + if (JSON.stringify(currentSettings.voice) !== JSON.stringify(initialSettings.voice)) { + changedFields.settings.voice = currentSettings.voice; + } + + // Check secrets with special handling + if (JSON.stringify(currentSettings.secrets) !== JSON.stringify(initialSettings.secrets)) { + const initialSecrets = initialSettings.secrets || {}; + const currentSecrets = currentSettings.secrets || {}; + + // Only include secrets that were added or modified + const changedSecrets: Record = {}; + let hasSecretChanges = false; + + // Find added or modified secrets + Object.entries(currentSecrets).forEach(([key, value]) => { + if (initialSecrets[key] !== value) { + changedSecrets[key] = value; + hasSecretChanges = true; + } + }); + + // Find deleted secrets (null values indicate deletion) + Object.keys(initialSecrets).forEach((key) => { + if (currentSecrets[key] === undefined) { + changedSecrets[key] = null; + hasSecretChanges = true; + } + }); + + if (hasSecretChanges) { + if (!changedFields.settings) changedFields.settings = {}; + changedFields.settings.secrets = changedSecrets; + } + } + + // If no specific settings changed, don't include settings object + if (Object.keys(changedFields.settings).length === 0) { + delete changedFields.settings; + } + } + + return changedFields; + }, [agent]); + + return { + agent, + updateField, + reset, + updateSettings, + setSettings, + + // Method to get only changed fields + getChangedFields, + + // Basic Info Tab + updateSetting, + updateSystemPrompt, + + // Secrets Tab + updateSecret, + removeSecret, + + // Content Tab + addContentItem, + removeContentItem, + updateContentItem, + + // Style Tab + addStyleRule, + removeStyleRule, + updateStyleRule, + setStyleArray, + + // Plugins Tab + addPlugin, + removePlugin, + setPlugins, + + // Avatar Tab + updateAvatar, + }; +} diff --git a/packages/client/src/hooks/use-partial-update.ts b/packages/client/src/hooks/use-partial-update.ts new file mode 100644 index 00000000000..60b36490c16 --- /dev/null +++ b/packages/client/src/hooks/use-partial-update.ts @@ -0,0 +1,284 @@ +import { useState, useCallback } from 'react'; + +/** + * A custom hook for handling partial updates of objects with nested JSONb fields. + * This hook ensures that updates to nested objects and arrays are properly + * managed when sending updates to the server. + * + * @param initialValue The initial state object + * @returns A tuple containing: + * - The current state object + * - A function to update a specific field (handles nested paths) + * - A function to add an item to an array field + * - A function to remove an item from an array field + * - A function to set the entire object + * - A function to reset to initial state + */ +export function usePartialUpdate(initialValue: T) { + const [value, setValue] = useState(initialValue); + + /** + * Updates a specific field in the object, handling nested paths + * + * @param path The path to the field to update (e.g., 'settings.voice.model') + * @param newValue The new value for the field + */ + const updateField = useCallback((path: string, newValue: K) => { + setValue((prevValue) => { + // Handle simple (non-nested) case + if (!path.includes('.')) { + return { + ...prevValue, + [path]: newValue, + } as T; + } + + // Handle nested paths + const pathParts = path.split('.'); + const fieldToUpdate = pathParts[0]; + const remainingPath = pathParts.slice(1).join('.'); + + // Handle arrays in path (e.g., 'style.all.0') + const isArrayIndex = !isNaN(Number(pathParts[1])); + + if (isArrayIndex) { + const arrayName = pathParts[0]; + const index = Number(pathParts[1]); + // Ensure we're working with an array and handle it safely + const currentValue = prevValue[arrayName as keyof T]; + const array = Array.isArray(currentValue) ? [...currentValue] : []; + + if (pathParts.length === 2) { + // Direct array item update + array[index] = newValue; + } else { + // Updating a property of an object in an array + const deeperPath = pathParts.slice(2).join('.'); + array[index] = updateNestedObject(array[index], deeperPath, newValue); + } + + return { + ...prevValue, + [arrayName]: array, + } as T; + } + + // Special case for settings.secrets path + if (path.startsWith('settings.secrets.')) { + const secretKey = path.split('.')[2]; + + const currentSettings = (prevValue as any).settings || {}; + const currentSecrets = currentSettings.secrets || {}; + + const newSecrets = { + ...currentSecrets, + [secretKey]: newValue, + }; + + return { + ...prevValue, + settings: { + ...currentSettings, + secrets: newSecrets, + }, + } as T; + } + + // Handle regular nested objects + const result = { + ...prevValue, + [fieldToUpdate]: updateNestedObject( + prevValue[fieldToUpdate as keyof T], + remainingPath, + newValue + ), + } as T; + + return result; + }); + }, []); + + /** + * Helper function to update a nested object + */ + const updateNestedObject = (obj: K, path: string, value: V): K => { + if (!path.includes('.')) { + return { + ...obj, + [path]: value, + } as unknown as K; + } + + const [field, ...remainingPath] = path.split('.'); + const nextPath = remainingPath.join('.'); + + return { + ...obj, + [field]: updateNestedObject((obj as any)[field] || {}, nextPath, value), + } as unknown as K; + }; + + /** + * Adds an item to an array field + * + * @param path Path to the array field + * @param item Item to add + */ + const addArrayItem = useCallback((path: string, item: V) => { + setValue((prevValue) => { + const pathParts = path.split('.'); + + // Handle simple array field + if (pathParts.length === 1) { + const fieldName = pathParts[0]; + const currentArray = Array.isArray(prevValue[fieldName as keyof T]) + ? [...(prevValue[fieldName as keyof T] as unknown as V[])] + : []; + + return { + ...prevValue, + [fieldName]: [...currentArray, item], + } as T; + } + + // Handle nested array field + const updatePath = path; + const currentValue = getNestedValue(prevValue, updatePath); + const currentArray = Array.isArray(currentValue) ? [...currentValue] : []; + + return setNestedValue(prevValue, updatePath, [...currentArray, item]); + }); + }, []); + + /** + * Removes an item from an array field + * + * @param path Path to the array field + * @param index Index of the item to remove + */ + const removeArrayItem = useCallback((path: string, index: number) => { + setValue((prevValue) => { + const pathParts = path.split('.'); + + // Handle simple array field + if (pathParts.length === 1) { + const fieldName = pathParts[0]; + const currentArray = Array.isArray(prevValue[fieldName as keyof T]) + ? [...(prevValue[fieldName as keyof T] as unknown as any[])] + : []; + + if (index < 0 || index >= currentArray.length) return prevValue; + + return { + ...prevValue, + [fieldName]: [...currentArray.slice(0, index), ...currentArray.slice(index + 1)], + } as T; + } + + // Handle nested array field + const updatePath = path; + const currentValue = getNestedValue(prevValue, updatePath); + + if (!Array.isArray(currentValue) || index < 0 || index >= currentValue.length) { + return prevValue; + } + + const newArray = [...currentValue.slice(0, index), ...currentValue.slice(index + 1)]; + return setNestedValue(prevValue, updatePath, newArray); + }); + }, []); + + /** + * Helper function to get a nested value from an object + */ + const getNestedValue = (obj: any, path: string): any => { + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined; + } + current = current[part]; + } + + return current; + }; + + /** + * Helper function to set a nested value in an object + */ + const setNestedValue = (obj: O, path: string, value: V): O => { + const parts = path.split('.'); + + if (parts.length === 1) { + return { + ...obj, + [parts[0]]: value, + } as O; + } + + const [first, ...rest] = parts; + const nextObj = (obj as any)[first] || {}; + + return { + ...obj, + [first]: setNestedValue(nextObj, rest.join('.'), value), + } as O; + }; + + /** + * Resets to the initial state + */ + const reset = useCallback(() => { + setValue(initialValue); + }, [initialValue]); + + // Special handling for updating the entire settings object + const updateSettings = useCallback( + (settings: any) => { + setValue((prevValue) => { + // Extract settings but remove 'secrets' key to avoid duplication + const { secrets, ...otherSettings } = settings; + + // Create the updated settings object + const updatedSettings = { + ...(prevValue as any).settings, // Start with existing settings + ...otherSettings, // Add other settings (not secrets) + }; + + // Only add secrets if it was included in the update + if (secrets) { + // Create a new secrets object that only contains non-null values + const filteredSecrets: Record = {}; + + Object.entries(secrets).forEach(([key, value]) => { + // If value is null, don't include it (this is how we delete) + if (value !== null) { + filteredSecrets[key] = value; + } + }); + + updatedSettings.secrets = filteredSecrets; + } + + const result = { + ...prevValue, + settings: updatedSettings, + } as T; + + return result; + }); + }, + [] // Remove value from dependencies to avoid unnecessary rerenders + ); + + return { + value, + updateField, + addArrayItem, + removeArrayItem, + reset, + updateSettings, + }; +} diff --git a/packages/client/src/lib/api.ts b/packages/client/src/lib/api.ts index 3afb578af4d..4546cc7db7b 100644 --- a/packages/client/src/lib/api.ts +++ b/packages/client/src/lib/api.ts @@ -254,12 +254,13 @@ export const apiClient = { }, deleteAgent: (agentId: string): Promise<{ success: boolean }> => fetcher({ url: `/agents/${agentId}`, method: 'DELETE' }), - updateAgent: (agentId: string, agent: Agent) => - fetcher({ + updateAgent: async (agentId: string, agent: Agent) => { + return fetcher({ url: `/agents/${agentId}`, method: 'PATCH', body: agent, - }), + }); + }, createAgent: (params: { characterPath?: string; characterJson?: Character }) => fetcher({ url: '/agents/', diff --git a/packages/client/src/lib/utils.ts b/packages/client/src/lib/utils.ts index 848bfd2dd94..816b359e560 100644 --- a/packages/client/src/lib/utils.ts +++ b/packages/client/src/lib/utils.ts @@ -44,7 +44,7 @@ export function urlToCharacterName(urlName: string): string { // crypto.randomUUID only works in https context in firefox export function randomUUID(): UUID { - return URL.createObjectURL(new Blob()).split('/').pop(); + return URL.createObjectURL(new Blob()).split('/').pop() as UUID; } export function getEntityId(): UUID { diff --git a/packages/core/package.json b/packages/core/package.json index 31f75e8537e..509aa26432f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -49,15 +49,21 @@ "lint-staged": "15.2.10", "nodemon": "3.1.7", "pm2": "5.4.3", - "prettier": "3.5.3", - "rimraf": "6.0.1", - "rollup": "2.79.2", - "ts-node": "10.9.2", - "tslib": "2.8.1", - "tsup": "^8.4.0", - "typescript": "5.8.2" + "prettier": "3.5.3", + "rimraf": "6.0.1", + "rollup": "2.79.2", + "ts-node": "10.9.2", + "tslib": "2.8.1", + "tsup": "^8.4.0", + "typescript": "5.8.2", + "@hapi/shot": "^6.0.1", + "@types/hapi": "^18.0.14" + }, "dependencies": { + "@hapi/shot": "^6.0.1", + "@types/hapi": "^18.0.14", + "dotenv": "16.4.5", "events": "^3.3.0", "glob": "11.0.0", diff --git a/packages/plugin-sql/src/base.ts b/packages/plugin-sql/src/base.ts index 7da4b062a81..9af92c325a3 100644 --- a/packages/plugin-sql/src/base.ts +++ b/packages/plugin-sql/src/base.ts @@ -261,6 +261,11 @@ export abstract class BaseDrizzleAdapter< } await this.db.transaction(async (tx) => { + // Handle settings update if present + if (agent.settings) { + agent.settings = await this.mergeAgentSettings(tx, agentId, agent.settings); + } + await tx .update(agentTable) .set({ @@ -285,6 +290,63 @@ export abstract class BaseDrizzleAdapter< }); } + /** + * Merges updated agent settings with existing settings in the database, + * with special handling for nested objects like secrets. + * @param tx - The database transaction + * @param agentId - The ID of the agent + * @param updatedSettings - The settings object with updates + * @returns The merged settings object + * @private + */ + private async mergeAgentSettings( + tx: DrizzleOperations, + agentId: UUID, + updatedSettings: any + ): Promise { + // First get the current agent data + const currentAgent = await tx + .select({ settings: agentTable.settings }) + .from(agentTable) + .where(eq(agentTable.id, agentId)) + .limit(1); + + if (currentAgent.length === 0 || !currentAgent[0].settings) { + return updatedSettings; + } + + const currentSettings = currentAgent[0].settings; + + // Handle secrets with special null-values treatment + if (updatedSettings.secrets) { + const currentSecrets = currentSettings.secrets || {}; + const updatedSecrets = updatedSettings.secrets; + + // Create a new secrets object + const mergedSecrets = { ...currentSecrets }; + + // Process the incoming secrets updates + for (const [key, value] of Object.entries(updatedSecrets)) { + if (value === null) { + // If value is null, remove the key + delete mergedSecrets[key]; + } else { + // Otherwise, update the value + mergedSecrets[key] = value; + } + } + + // Replace the secrets in updatedSettings with our processed version + updatedSettings.secrets = mergedSecrets; + } + + // Deep merge the settings objects + return { + ...currentSettings, + ...updatedSettings, + }; + } + /** * Asynchronously deletes an agent with the specified UUID and all related entries. *