@elizaos/client 1.5.5-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/dist/assets/empty-module-CLMscLYw.js +1 -0
  4. package/dist/assets/main-BBZ_3lkn.css +5999 -0
  5. package/dist/assets/main-C5zNUkXH.js +7 -0
  6. package/dist/assets/main-Dz64ENQg.js +614 -0
  7. package/dist/assets/react-vendor-DM5m98rr.js +545 -0
  8. package/dist/assets/ui-vendor-BQCqNqg0.js +1 -0
  9. package/dist/elizaos-avatar.png +0 -0
  10. package/dist/elizaos-icon.png +0 -0
  11. package/dist/elizaos-logo-light.png +0 -0
  12. package/dist/elizaos.webp +0 -0
  13. package/dist/favicon.ico +0 -0
  14. package/dist/images/agents/agent1.png +0 -0
  15. package/dist/images/agents/agent2.png +0 -0
  16. package/dist/images/agents/agent3.png +0 -0
  17. package/dist/images/agents/agent4.png +0 -0
  18. package/dist/images/agents/agent5.png +0 -0
  19. package/dist/index.html +14 -0
  20. package/index.html +24 -0
  21. package/package.json +159 -0
  22. package/postcss.config.js +3 -0
  23. package/public/elizaos-avatar.png +0 -0
  24. package/public/elizaos-icon.png +0 -0
  25. package/public/elizaos-logo-light.png +0 -0
  26. package/public/elizaos.webp +0 -0
  27. package/public/favicon.ico +0 -0
  28. package/public/images/agents/agent1.png +0 -0
  29. package/public/images/agents/agent2.png +0 -0
  30. package/public/images/agents/agent3.png +0 -0
  31. package/public/images/agents/agent4.png +0 -0
  32. package/public/images/agents/agent5.png +0 -0
  33. package/src/App.tsx +222 -0
  34. package/src/components/AgentDetailsPanel.tsx +147 -0
  35. package/src/components/ChatInputArea.tsx +196 -0
  36. package/src/components/ChatMessageListComponent.tsx +139 -0
  37. package/src/components/actionTool.tsx +186 -0
  38. package/src/components/add-agent-card.tsx +77 -0
  39. package/src/components/agent-action-viewer.tsx +816 -0
  40. package/src/components/agent-avatar-stack.tsx +121 -0
  41. package/src/components/agent-card.cy.tsx +259 -0
  42. package/src/components/agent-card.tsx +177 -0
  43. package/src/components/agent-creator.tsx +142 -0
  44. package/src/components/agent-log-viewer.tsx +645 -0
  45. package/src/components/agent-memory-edit-overlay.tsx +461 -0
  46. package/src/components/agent-memory-viewer.tsx +504 -0
  47. package/src/components/agent-settings.tsx +270 -0
  48. package/src/components/agent-sidebar.tsx +178 -0
  49. package/src/components/api-key-dialog.tsx +113 -0
  50. package/src/components/app-sidebar.tsx +685 -0
  51. package/src/components/array-input.tsx +116 -0
  52. package/src/components/audio-recorder.tsx +292 -0
  53. package/src/components/avatar-panel.tsx +141 -0
  54. package/src/components/character-form.tsx +1138 -0
  55. package/src/components/chat.tsx +1813 -0
  56. package/src/components/combobox.tsx +187 -0
  57. package/src/components/confirmation-dialog.tsx +59 -0
  58. package/src/components/connection-error-banner.tsx +101 -0
  59. package/src/components/connection-status.cy.tsx +73 -0
  60. package/src/components/connection-status.tsx +155 -0
  61. package/src/components/copy-button.tsx +35 -0
  62. package/src/components/delete-button.tsx +24 -0
  63. package/src/components/env-settings.tsx +261 -0
  64. package/src/components/group-card.tsx +160 -0
  65. package/src/components/group-panel.tsx +543 -0
  66. package/src/components/input-copy.tsx +21 -0
  67. package/src/components/logs-page.tsx +41 -0
  68. package/src/components/media-content.tsx +385 -0
  69. package/src/components/memory-graph.tsx +170 -0
  70. package/src/components/missing-secrets-dialog.tsx +72 -0
  71. package/src/components/onboarding-tour.tsx +247 -0
  72. package/src/components/page-title.tsx +8 -0
  73. package/src/components/plugins-panel.tsx +383 -0
  74. package/src/components/profile-card.tsx +66 -0
  75. package/src/components/profile-overlay.tsx +283 -0
  76. package/src/components/retry-button.tsx +28 -0
  77. package/src/components/secret-panel.tsx +1505 -0
  78. package/src/components/server-management.tsx +264 -0
  79. package/src/components/split-button.tsx +148 -0
  80. package/src/components/stop-agent-button.tsx +99 -0
  81. package/src/components/ui/alert-dialog.cy.tsx +333 -0
  82. package/src/components/ui/alert-dialog.tsx +115 -0
  83. package/src/components/ui/alert.tsx +49 -0
  84. package/src/components/ui/avatar.cy.tsx +180 -0
  85. package/src/components/ui/avatar.tsx +57 -0
  86. package/src/components/ui/badge.cy.tsx +146 -0
  87. package/src/components/ui/badge.tsx +43 -0
  88. package/src/components/ui/button.cy.tsx +177 -0
  89. package/src/components/ui/button.tsx +56 -0
  90. package/src/components/ui/card.cy.tsx +160 -0
  91. package/src/components/ui/card.tsx +73 -0
  92. package/src/components/ui/chat/animated-markdown.tsx +59 -0
  93. package/src/components/ui/chat/chat-bubble.tsx +178 -0
  94. package/src/components/ui/chat/chat-container.tsx +51 -0
  95. package/src/components/ui/chat/chat-input.cy.tsx +169 -0
  96. package/src/components/ui/chat/chat-input.tsx +47 -0
  97. package/src/components/ui/chat/chat-message-list.tsx +61 -0
  98. package/src/components/ui/chat/chat-tts-button.tsx +199 -0
  99. package/src/components/ui/chat/code-block.tsx +79 -0
  100. package/src/components/ui/chat/expandable-chat.tsx +131 -0
  101. package/src/components/ui/chat/hooks/useAutoScroll.ts +86 -0
  102. package/src/components/ui/chat/markdown.tsx +209 -0
  103. package/src/components/ui/chat/message-loading.tsx +48 -0
  104. package/src/components/ui/checkbox.cy.tsx +170 -0
  105. package/src/components/ui/checkbox.tsx +30 -0
  106. package/src/components/ui/collapsible.cy.tsx +283 -0
  107. package/src/components/ui/collapsible.tsx +9 -0
  108. package/src/components/ui/command.cy.tsx +313 -0
  109. package/src/components/ui/command.tsx +143 -0
  110. package/src/components/ui/dialog.cy.tsx +279 -0
  111. package/src/components/ui/dialog.tsx +104 -0
  112. package/src/components/ui/dropdown-menu.cy.tsx +273 -0
  113. package/src/components/ui/dropdown-menu.tsx +281 -0
  114. package/src/components/ui/input.cy.tsx +82 -0
  115. package/src/components/ui/input.tsx +27 -0
  116. package/src/components/ui/label.cy.tsx +157 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/resizable.tsx +42 -0
  119. package/src/components/ui/scroll-area.cy.tsx +242 -0
  120. package/src/components/ui/scroll-area.tsx +46 -0
  121. package/src/components/ui/select.cy.tsx +277 -0
  122. package/src/components/ui/select.tsx +155 -0
  123. package/src/components/ui/separator.cy.tsx +145 -0
  124. package/src/components/ui/separator.tsx +29 -0
  125. package/src/components/ui/sheet.cy.tsx +324 -0
  126. package/src/components/ui/sheet.tsx +119 -0
  127. package/src/components/ui/sidebar.tsx +734 -0
  128. package/src/components/ui/skeleton.cy.tsx +149 -0
  129. package/src/components/ui/skeleton.tsx +17 -0
  130. package/src/components/ui/split-button.cy.tsx +274 -0
  131. package/src/components/ui/split-button.tsx +112 -0
  132. package/src/components/ui/switch.tsx +28 -0
  133. package/src/components/ui/tabs.cy.tsx +271 -0
  134. package/src/components/ui/tabs.tsx +53 -0
  135. package/src/components/ui/textarea.cy.tsx +136 -0
  136. package/src/components/ui/textarea.tsx +26 -0
  137. package/src/components/ui/toast.cy.tsx +209 -0
  138. package/src/components/ui/toast.tsx +126 -0
  139. package/src/components/ui/toaster.tsx +29 -0
  140. package/src/components/ui/tooltip.cy.tsx +244 -0
  141. package/src/components/ui/tooltip.tsx +30 -0
  142. package/src/config/agent-templates.ts +349 -0
  143. package/src/config/voice-models.ts +181 -0
  144. package/src/constants.ts +23 -0
  145. package/src/context/AuthContext.tsx +44 -0
  146. package/src/context/ConnectionContext.tsx +194 -0
  147. package/src/entry.tsx +9 -0
  148. package/src/hooks/__tests__/use-agent-tab-state.test.ts +137 -0
  149. package/src/hooks/__tests__/use-agent-update.test.tsx +250 -0
  150. package/src/hooks/__tests__/use-character-convert.test.ts +102 -0
  151. package/src/hooks/__tests__/use-panel-width-state.test.ts +243 -0
  152. package/src/hooks/__tests__/use-sidebar-state.test.ts +117 -0
  153. package/src/hooks/use-agent-management.ts +130 -0
  154. package/src/hooks/use-agent-tab-state.ts +74 -0
  155. package/src/hooks/use-agent-update.ts +469 -0
  156. package/src/hooks/use-character-convert.ts +138 -0
  157. package/src/hooks/use-confirmation.ts +55 -0
  158. package/src/hooks/use-delete-agent.ts +123 -0
  159. package/src/hooks/use-dm-channels.ts +198 -0
  160. package/src/hooks/use-elevenlabs-voices.ts +83 -0
  161. package/src/hooks/use-file-upload.ts +224 -0
  162. package/src/hooks/use-mobile.tsx +19 -0
  163. package/src/hooks/use-onboarding.tsx +49 -0
  164. package/src/hooks/use-panel-width-state.ts +147 -0
  165. package/src/hooks/use-partial-update.ts +288 -0
  166. package/src/hooks/use-plugin-details.ts +462 -0
  167. package/src/hooks/use-plugins.ts +119 -0
  168. package/src/hooks/use-query-hooks.ts +1263 -0
  169. package/src/hooks/use-server-agents.ts +62 -0
  170. package/src/hooks/use-server-version.tsx +47 -0
  171. package/src/hooks/use-sidebar-state.ts +50 -0
  172. package/src/hooks/use-socket-chat.ts +264 -0
  173. package/src/hooks/use-toast.ts +260 -0
  174. package/src/hooks/use-version.tsx +64 -0
  175. package/src/index.css +146 -0
  176. package/src/lib/api-client-config.ts +53 -0
  177. package/src/lib/api-type-mappers.ts +196 -0
  178. package/src/lib/export-utils.ts +123 -0
  179. package/src/lib/logger.ts +19 -0
  180. package/src/lib/media-utils.ts +170 -0
  181. package/src/lib/pca.test.ts +17 -0
  182. package/src/lib/pca.ts +52 -0
  183. package/src/lib/socketio-manager.ts +664 -0
  184. package/src/lib/utils.ts +168 -0
  185. package/src/main.tsx +16 -0
  186. package/src/mocks/empty-module.ts +12 -0
  187. package/src/mocks/node-module.ts +57 -0
  188. package/src/polyfills.ts +37 -0
  189. package/src/routes/agent-detail.tsx +30 -0
  190. package/src/routes/agent-list.tsx +27 -0
  191. package/src/routes/agent-settings.tsx +48 -0
  192. package/src/routes/character-detail.tsx +52 -0
  193. package/src/routes/character-form.tsx +79 -0
  194. package/src/routes/character-list.tsx +38 -0
  195. package/src/routes/chat.tsx +128 -0
  196. package/src/routes/createAgent.tsx +13 -0
  197. package/src/routes/group-new.tsx +50 -0
  198. package/src/routes/group.tsx +29 -0
  199. package/src/routes/home.tsx +218 -0
  200. package/src/routes/not-found.tsx +71 -0
  201. package/src/test/setup.ts +154 -0
  202. package/src/types/crypto-browserify.d.ts +4 -0
  203. package/src/types/index.ts +13 -0
  204. package/src/types/rooms.ts +8 -0
  205. package/src/types.ts +84 -0
  206. package/src/vite-env.d.ts +40 -0
  207. package/tailwind.config.ts +90 -0
  208. package/tsconfig.json +10 -0
  209. package/vite.config.ts +102 -0
@@ -0,0 +1,1138 @@
1
+ import ArrayInput from '@/components/array-input';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Input } from '@/components/ui/input';
4
+ import { Label } from '@/components/ui/label';
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
6
+ import { Textarea } from '@/components/ui/textarea';
7
+ import { AVATAR_IMAGE_MAX_SIZE, FIELD_REQUIREMENT_TYPE, FIELD_REQUIREMENTS } from '@/constants';
8
+ import { useToast } from '@/hooks/use-toast';
9
+ import { exportCharacterAsJson } from '@/lib/export-utils';
10
+ import { compressImage } from '@/lib/utils';
11
+ import type { Agent, Character } from '@elizaos/core';
12
+ import type React from 'react';
13
+ import {
14
+ type FormEvent,
15
+ type ReactNode,
16
+ useState,
17
+ useMemo,
18
+ useCallback,
19
+ useRef,
20
+ useEffect,
21
+ } from 'react';
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ SelectGroup,
29
+ SelectLabel,
30
+ SelectSeparator,
31
+ } from '@/components/ui/select';
32
+ import {
33
+ getAllVoiceModels,
34
+ getVoiceModelByValue,
35
+ providerPluginMap,
36
+ openAIVoiceModels,
37
+ elevenLabsVoiceModels,
38
+ } from '../config/voice-models';
39
+ import { useElevenLabsVoices } from '@/hooks/use-elevenlabs-voices';
40
+ import {
41
+ Trash,
42
+ Loader2,
43
+ RotateCcw,
44
+ ArrowDownToLine,
45
+ ArrowUpFromLine,
46
+ Save,
47
+ StopCircle,
48
+ MoreVertical,
49
+ } from 'lucide-react';
50
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
51
+ import {
52
+ DropdownMenu,
53
+ DropdownMenuContent,
54
+ DropdownMenuItem,
55
+ DropdownMenuSeparator,
56
+ DropdownMenuTrigger,
57
+ } from '@/components/ui/dropdown-menu';
58
+ import { agentTemplates, getTemplateById } from '@/config/agent-templates';
59
+ import { cn } from '@/lib/utils';
60
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
61
+
62
+ import type { SecretPanelRef } from './secret-panel';
63
+ import { MissingSecretsDialog } from './missing-secrets-dialog';
64
+ import { useRequiredSecrets } from '@/hooks/use-plugin-details';
65
+ import { createElizaClient } from '@/lib/api-client-config';
66
+ import { V1Character, useConvertCharacter } from '@/hooks/use-character-convert';
67
+
68
+ export type InputField = {
69
+ name: string;
70
+ title: string;
71
+ description?: string;
72
+ fieldType: 'text' | 'textarea' | 'email' | 'url' | 'checkbox' | 'select';
73
+ getValue: (agent: Agent) => string;
74
+ options?: { value: string; label: string }[];
75
+ tooltip?: string;
76
+ };
77
+
78
+ export type ArrayField = {
79
+ path: string;
80
+ title: string;
81
+ description?: string;
82
+ getData: (agent: Agent) => string[];
83
+ tooltip?: string;
84
+ };
85
+
86
+ enum SECTION_TYPE {
87
+ INPUT = 'input',
88
+ ARRAY = 'array',
89
+ }
90
+
91
+ type customComponent = {
92
+ name: string;
93
+ component: ReactNode;
94
+ };
95
+
96
+ export type CharacterFormProps = {
97
+ title: string;
98
+ description: string;
99
+ onSubmit: (character: Agent) => Promise<void>;
100
+ onDelete?: () => void;
101
+ onReset?: () => void;
102
+ onStopAgent?: () => void;
103
+ isAgent?: boolean;
104
+ isDeleting?: boolean;
105
+ isStopping?: boolean;
106
+ customComponents?: customComponent[];
107
+ characterValue: Agent;
108
+ setCharacterValue: {
109
+ updateField: <T>(path: string, value: T) => void;
110
+ addArrayItem?: <T>(path: string, item: T) => void;
111
+ removeArrayItem?: (path: string, index: number) => void;
112
+ updateSetting?: (path: string, value: any) => void;
113
+ importAgent?: (value: Character) => void;
114
+ [key: string]: any;
115
+ };
116
+ onTemplateChange?: () => void;
117
+ secretPanelRef?: React.RefObject<SecretPanelRef | null>;
118
+ };
119
+
120
+ // Custom hook to detect container width and determine if labels should be shown
121
+ const useContainerWidth = (threshold: number = 768) => {
122
+ const containerRef = useRef<HTMLDivElement>(null);
123
+ const [showLabels, setShowLabels] = useState(true);
124
+ const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
125
+ const currentWidthRef = useRef<number>(0);
126
+
127
+ useEffect(() => {
128
+ const container = containerRef.current;
129
+ if (!container) return;
130
+
131
+ // Debounced resize handler
132
+ const handleResize = (width: number) => {
133
+ // Clear existing timer
134
+ if (debounceTimerRef.current) {
135
+ clearTimeout(debounceTimerRef.current);
136
+ }
137
+
138
+ // Set new timer
139
+ debounceTimerRef.current = setTimeout(() => {
140
+ const shouldShowLabels = width >= threshold;
141
+ // Only update if the value actually changes
142
+ setShowLabels((prev) => {
143
+ if (prev !== shouldShowLabels) {
144
+ return shouldShowLabels;
145
+ }
146
+ return prev;
147
+ });
148
+ currentWidthRef.current = width;
149
+ }, 150); // 150ms debounce
150
+ };
151
+
152
+ const resizeObserver = new ResizeObserver((entries) => {
153
+ for (const entry of entries) {
154
+ const { width } = entry.contentRect;
155
+ // Only trigger if width actually changed significantly (more than 5px)
156
+ if (Math.abs(width - currentWidthRef.current) > 5) {
157
+ handleResize(width);
158
+ }
159
+ }
160
+ });
161
+
162
+ resizeObserver.observe(container);
163
+
164
+ // Initial check with delay to avoid race conditions
165
+ setTimeout(() => {
166
+ const { width } = container.getBoundingClientRect();
167
+ currentWidthRef.current = width;
168
+ setShowLabels(width >= threshold);
169
+ }, 0);
170
+
171
+ // Cleanup
172
+ return () => {
173
+ resizeObserver.disconnect();
174
+ if (debounceTimerRef.current) {
175
+ clearTimeout(debounceTimerRef.current);
176
+ }
177
+ };
178
+ }, [threshold]);
179
+
180
+ return { containerRef, showLabels };
181
+ };
182
+
183
+ export default function CharacterForm({
184
+ characterValue,
185
+ setCharacterValue,
186
+ title,
187
+ description,
188
+ onSubmit,
189
+ onDelete,
190
+ onReset,
191
+ onStopAgent,
192
+ isDeleting = false,
193
+ isStopping = false,
194
+ customComponents = [],
195
+ onTemplateChange,
196
+ secretPanelRef,
197
+ }: CharacterFormProps) {
198
+ const { toast } = useToast();
199
+ const { data: elevenlabsVoices, isLoading: isLoadingVoices } = useElevenLabsVoices();
200
+ const [isSubmitting, setIsSubmitting] = useState(false);
201
+ const [selectedTemplate, setSelectedTemplate] = useState<string>('none');
202
+ const [activeTab, setActiveTab] = useState('basic');
203
+ const tabsContainerRef = useRef<HTMLDivElement>(null);
204
+ const [showLeftScroll, setShowLeftScroll] = useState(false);
205
+ const [showRightScroll, setShowRightScroll] = useState(false);
206
+ const [showMissingSecretsDialog, setShowMissingSecretsDialog] = useState(false);
207
+ const [pendingSubmit, setPendingSubmit] = useState<Agent | null>(null);
208
+ const [globalEnvs, setGlobalEnvs] = useState<Record<string, string>>({});
209
+
210
+ // Get required secrets based on enabled plugins
211
+ const enabledPlugins = useMemo(() => characterValue?.plugins || [], [characterValue?.plugins]);
212
+ const { requiredSecrets } = useRequiredSecrets(enabledPlugins);
213
+
214
+ const { convertCharacter } = useConvertCharacter();
215
+
216
+ // Fetch global environment variables
217
+ useEffect(() => {
218
+ const fetchGlobalEnvs = async () => {
219
+ try {
220
+ const elizaClient = createElizaClient();
221
+ const data = await elizaClient.system.getEnvironment();
222
+ setGlobalEnvs(data || {});
223
+ } catch (error) {
224
+ console.error('Failed to fetch global environment variables:', error);
225
+ setGlobalEnvs({});
226
+ }
227
+ };
228
+
229
+ fetchGlobalEnvs();
230
+ }, []);
231
+
232
+ // Use the custom hook to detect container width
233
+ const { containerRef, showLabels } = useContainerWidth(640); // Adjust threshold as needed
234
+
235
+ // Check if tabs need scroll buttons
236
+ const checkScrollButtons = useCallback(() => {
237
+ const container = tabsContainerRef.current;
238
+ if (!container) return;
239
+
240
+ const { scrollLeft, scrollWidth, clientWidth } = container;
241
+ setShowLeftScroll(scrollLeft > 0);
242
+ setShowRightScroll(scrollLeft + clientWidth < scrollWidth - 1);
243
+ }, []);
244
+
245
+ useEffect(() => {
246
+ const container = tabsContainerRef.current;
247
+ if (!container) return;
248
+
249
+ checkScrollButtons();
250
+ container.addEventListener('scroll', checkScrollButtons);
251
+ window.addEventListener('resize', checkScrollButtons);
252
+
253
+ return () => {
254
+ container.removeEventListener('scroll', checkScrollButtons);
255
+ window.removeEventListener('resize', checkScrollButtons);
256
+ };
257
+ }, [checkScrollButtons, customComponents.length]);
258
+
259
+ const scrollTabs = (direction: 'left' | 'right') => {
260
+ const container = tabsContainerRef.current;
261
+ if (!container) return;
262
+
263
+ const scrollAmount = container.clientWidth * 0.8;
264
+ container.scrollBy({
265
+ left: direction === 'left' ? -scrollAmount : scrollAmount,
266
+ behavior: 'smooth',
267
+ });
268
+ };
269
+
270
+ // Get all voice models, using the dynamic ElevenLabs voices when available
271
+ const allVoiceModels = useMemo(() => {
272
+ const staticModels = getAllVoiceModels();
273
+
274
+ // If we have dynamically loaded ElevenLabs voices, replace the static ones
275
+ if (elevenlabsVoices && !isLoadingVoices) {
276
+ // Filter out the static ElevenLabs voices
277
+ const nonElevenLabsModels = staticModels.filter((model) => model.provider !== 'elevenlabs');
278
+ // Return combined models with dynamic ElevenLabs voices
279
+ return [...nonElevenLabsModels, ...elevenlabsVoices];
280
+ }
281
+
282
+ // Otherwise return the static models
283
+ return staticModels;
284
+ }, [elevenlabsVoices, isLoadingVoices]);
285
+
286
+ // Define form schema with dynamic voice model options
287
+ const AGENT_FORM_SCHEMA = useMemo(
288
+ () => [
289
+ {
290
+ sectionTitle: 'Basic Info',
291
+ sectionValue: 'basic',
292
+ sectionType: SECTION_TYPE.INPUT,
293
+ fields: [
294
+ {
295
+ title: 'Name',
296
+ name: 'name',
297
+ description: 'The primary identifier for this agent',
298
+ fieldType: 'text',
299
+ getValue: (char) => char.name || '',
300
+ tooltip:
301
+ 'Display name that will be visible to users. Required for identification purposes.',
302
+ },
303
+ {
304
+ title: 'Username',
305
+ name: 'username',
306
+ description: 'Used in URLs and API endpoints',
307
+ fieldType: 'text',
308
+ getValue: (char) => char.username || '',
309
+ tooltip: 'Unique identifier for your agent. Used in APIs/URLs and Rooms.',
310
+ },
311
+ {
312
+ title: 'System',
313
+ name: 'system',
314
+ description: 'System prompt defining agent behavior',
315
+ fieldType: 'textarea',
316
+ getValue: (char) => char.system || '',
317
+ tooltip:
318
+ 'Instructions for the AI model that establish core behavior patterns and personality traits.',
319
+ },
320
+ {
321
+ title: 'Voice Model',
322
+ name: 'settings.voice.model',
323
+ description: 'Voice model for audio synthesis',
324
+ fieldType: 'select',
325
+ getValue: (char) => (char.settings as Record<string, any>)?.voice?.model || '',
326
+ options: allVoiceModels.map((model) => ({
327
+ value: model.value,
328
+ label: model.label,
329
+ })),
330
+ tooltip: "Select a voice that aligns with the agent's intended persona.",
331
+ },
332
+ ] as InputField[],
333
+ },
334
+ {
335
+ sectionTitle: 'Content',
336
+ sectionValue: 'content',
337
+ sectionType: SECTION_TYPE.ARRAY,
338
+ fields: [
339
+ {
340
+ title: 'Bio',
341
+ description: 'Bio data for this agent',
342
+ path: 'bio',
343
+ getData: (char) => (Array.isArray(char.bio) ? char.bio : []),
344
+ tooltip: "Biographical details that establish the agent's background and context.",
345
+ },
346
+ {
347
+ title: 'Topics',
348
+ description: 'Topics this agent can talk about',
349
+ path: 'topics',
350
+ getData: (char) => char.topics || [],
351
+ tooltip: 'Subject domains the agent can discuss with confidence.',
352
+ },
353
+ {
354
+ title: 'Adjectives',
355
+ description: 'Descriptive personality traits',
356
+ path: 'adjectives',
357
+ getData: (char) => char.adjectives || [],
358
+ tooltip: "Key personality attributes that define the agent's character.",
359
+ },
360
+ ] as ArrayField[],
361
+ },
362
+ {
363
+ sectionTitle: 'Style',
364
+ sectionValue: 'style',
365
+ sectionType: SECTION_TYPE.ARRAY,
366
+ fields: [
367
+ {
368
+ title: 'All Styles',
369
+ description: 'Writing style for all content types',
370
+ path: 'style.all',
371
+ getData: (char) => char.style?.all || [],
372
+ tooltip: 'Core writing style guidelines applied across all content formats.',
373
+ },
374
+ {
375
+ title: 'Chat Style',
376
+ description: 'Style specific to chat interactions',
377
+ path: 'style.chat',
378
+ getData: (char) => char.style?.chat || [],
379
+ tooltip: 'Writing style specific to conversational exchanges.',
380
+ },
381
+ {
382
+ title: 'Post Style',
383
+ description: 'Style for long-form content',
384
+ path: 'style.post',
385
+ getData: (char) => char.style?.post || [],
386
+ tooltip: 'Writing style for structured content such as articles or posts.',
387
+ },
388
+ ] as ArrayField[],
389
+ },
390
+ ],
391
+ [allVoiceModels]
392
+ );
393
+
394
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
395
+ const { name, value, type } = e.target;
396
+ const checked = (e.target as HTMLInputElement).checked;
397
+
398
+ if (type === 'checkbox') {
399
+ setCharacterValue.updateField(name, checked);
400
+ } else if (name.startsWith('settings.')) {
401
+ // Handle nested settings fields like settings.voice.model
402
+ const path = name.substring(9); // Remove 'settings.' prefix
403
+
404
+ if (setCharacterValue.updateSetting) {
405
+ // Use the specialized method if available
406
+ setCharacterValue.updateSetting(path, value);
407
+ } else {
408
+ // Fall back to generic updateField
409
+ setCharacterValue.updateField(name, value);
410
+ }
411
+ } else {
412
+ setCharacterValue.updateField(name, value);
413
+ }
414
+ };
415
+
416
+ const handleVoiceModelChange = (value: string, name: string) => {
417
+ if (name.startsWith('settings.')) {
418
+ const path = name.substring(9); // Remove 'settings.' prefix
419
+
420
+ if (setCharacterValue.updateSetting) {
421
+ // Use the specialized method if available
422
+ setCharacterValue.updateSetting(path, value);
423
+
424
+ // Handle voice model change and required plugins
425
+ if (path === 'voice.model' && value) {
426
+ const voiceModel = getVoiceModelByValue(value);
427
+ if (voiceModel) {
428
+ const currentPlugins = Array.isArray(characterValue.plugins)
429
+ ? [...characterValue.plugins]
430
+ : [];
431
+ const previousVoiceModel = getVoiceModelByValue(
432
+ (characterValue.settings as Record<string, any>)?.voice?.model
433
+ );
434
+
435
+ // Get the required plugin for the new voice model
436
+ const requiredPlugin = providerPluginMap[voiceModel.provider];
437
+
438
+ // Add the required plugin for the selected voice model
439
+ const newPlugins = [...currentPlugins];
440
+ if (requiredPlugin && !currentPlugins.includes(requiredPlugin)) {
441
+ newPlugins.push(requiredPlugin);
442
+ }
443
+
444
+ // Update the plugins
445
+ if (setCharacterValue.setPlugins) {
446
+ setCharacterValue.setPlugins(newPlugins);
447
+ } else if (setCharacterValue.updateField) {
448
+ setCharacterValue.updateField('plugins', newPlugins);
449
+ }
450
+
451
+ // Show toast notification
452
+ if (previousVoiceModel?.provider !== voiceModel.provider) {
453
+ toast({
454
+ title: 'Plugin Updated',
455
+ description: `${requiredPlugin} plugin has been set for the selected voice model.`,
456
+ });
457
+ }
458
+ }
459
+ }
460
+ } else {
461
+ // Fall back to generic updateField
462
+ setCharacterValue.updateField(name, value);
463
+ }
464
+ } else {
465
+ setCharacterValue.updateField(name, value);
466
+ }
467
+ };
468
+
469
+ const updateArray = (path: string, newData: string[]) => {
470
+ // If the path is a simple field name
471
+ if (!path.includes('.')) {
472
+ setCharacterValue.updateField(path, newData);
473
+ return;
474
+ }
475
+
476
+ // Handle nested paths (e.g. style.all)
477
+ const parts = path.split('.');
478
+ if (parts.length === 2 && parts[0] === 'style') {
479
+ // For style arrays, use the setStyleArray method if available
480
+ if (setCharacterValue.setStyleArray) {
481
+ setCharacterValue.setStyleArray(parts[1] as 'all' | 'chat' | 'post', newData);
482
+ } else {
483
+ setCharacterValue.updateField(path, newData);
484
+ }
485
+ return;
486
+ }
487
+
488
+ // Default case - just update the field
489
+ setCharacterValue.updateField(path, newData);
490
+ };
491
+
492
+ const ensureAvatarSize = async (char: Agent): Promise<Agent> => {
493
+ if (char.settings?.avatar) {
494
+ const img = new Image();
495
+ img.src = char.settings.avatar as string;
496
+ await new Promise((resolve) => (img.onload = resolve));
497
+
498
+ if (img.width > AVATAR_IMAGE_MAX_SIZE || img.height > AVATAR_IMAGE_MAX_SIZE) {
499
+ const response = await fetch(char.settings.avatar as string);
500
+ const blob = await response.blob();
501
+ const file = new File([blob], 'avatar.jpg', { type: blob.type });
502
+ const compressedImage = await compressImage(file);
503
+ return {
504
+ ...char,
505
+ settings: {
506
+ ...char.settings,
507
+ avatar: compressedImage,
508
+ },
509
+ };
510
+ }
511
+ }
512
+ return char;
513
+ };
514
+
515
+ const handleFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
516
+ e.preventDefault();
517
+ setIsSubmitting(true);
518
+
519
+ try {
520
+ const updatedCharacter = await ensureAvatarSize(characterValue);
521
+
522
+ // Validate required secrets
523
+ let missingSecrets: string[] = [];
524
+
525
+ // If secret panel is mounted, use it for validation (has most up-to-date data)
526
+ if (secretPanelRef?.current) {
527
+ const secretValidation = secretPanelRef.current.validateSecrets();
528
+ missingSecrets = secretValidation.missingSecrets;
529
+ } else {
530
+ // Secret panel not mounted - validate based on current character settings
531
+ const secretsObj = updatedCharacter.settings?.secrets;
532
+ const currentSecrets =
533
+ secretsObj && typeof secretsObj === 'object' && !Array.isArray(secretsObj)
534
+ ? (secretsObj as Record<string, any>)
535
+ : {};
536
+
537
+ missingSecrets = requiredSecrets
538
+ .filter((secret) => {
539
+ const value = currentSecrets[secret.name];
540
+ // Check agent-specific secret
541
+ if (value && typeof value === 'string' && value.trim() !== '') {
542
+ return false;
543
+ }
544
+ // Check global environment
545
+ const globalValue = globalEnvs[secret.name];
546
+ if (globalValue && globalValue.trim() !== '') {
547
+ return false;
548
+ }
549
+ return true;
550
+ })
551
+ .map((secret) => secret.name);
552
+ }
553
+
554
+ if (missingSecrets.length > 0) {
555
+ // Show the warning dialog
556
+ setIsSubmitting(false);
557
+ setPendingSubmit(updatedCharacter);
558
+ setShowMissingSecretsDialog(true);
559
+ return;
560
+ }
561
+
562
+ await onSubmit(updatedCharacter);
563
+ } catch (error) {
564
+ toast({
565
+ title: 'Error',
566
+ description: error instanceof Error ? error.message : 'Failed to update agent',
567
+ variant: 'destructive',
568
+ });
569
+ } finally {
570
+ setIsSubmitting(false);
571
+ }
572
+ };
573
+
574
+ // Handle confirmation from missing secrets dialog
575
+ const handleConfirmSaveWithMissingSecrets = async () => {
576
+ setShowMissingSecretsDialog(false);
577
+ if (pendingSubmit) {
578
+ setIsSubmitting(true);
579
+ try {
580
+ await onSubmit(pendingSubmit);
581
+ } catch (error) {
582
+ toast({
583
+ title: 'Error',
584
+ description: error instanceof Error ? error.message : 'Failed to update agent',
585
+ variant: 'destructive',
586
+ });
587
+ } finally {
588
+ setIsSubmitting(false);
589
+ setPendingSubmit(null);
590
+ }
591
+ }
592
+ };
593
+
594
+ // Handle cancellation from missing secrets dialog
595
+ const handleCancelSaveWithMissingSecrets = () => {
596
+ setShowMissingSecretsDialog(false);
597
+ setPendingSubmit(null);
598
+ // Switch to the Secret tab to show the user what's missing
599
+ setActiveTab('custom-Secret');
600
+ };
601
+
602
+ const renderInputField = (field: InputField) => (
603
+ <div
604
+ key={field.name}
605
+ className={`w-full ${field.name === 'name' ? 'agent-form-name' : ''} ${field.name === 'system' ? 'agent-form-system-prompt' : ''}`}
606
+ >
607
+ <Label htmlFor={field.name} className="text-sm font-normal block mb-2">
608
+ {field.title}
609
+ {field.name in FIELD_REQUIREMENTS &&
610
+ (FIELD_REQUIREMENTS as Record<string, FIELD_REQUIREMENT_TYPE>)[field.name] ===
611
+ FIELD_REQUIREMENT_TYPE.REQUIRED && <span className="text-destructive ml-1">*</span>}
612
+ </Label>
613
+
614
+ {field.fieldType === 'textarea' ? (
615
+ <Textarea
616
+ id={field.name}
617
+ name={field.name}
618
+ value={field.getValue(characterValue)}
619
+ onChange={handleChange}
620
+ className="min-h-[120px] resize-y"
621
+ />
622
+ ) : field.fieldType === 'checkbox' ? (
623
+ <Input
624
+ id={field.name}
625
+ name={field.name}
626
+ type="checkbox"
627
+ checked={(characterValue as Record<string, any>)[field.name] === 'true'}
628
+ onChange={handleChange}
629
+ />
630
+ ) : field.fieldType === 'select' ? (
631
+ <Select
632
+ name={field.name}
633
+ value={field.getValue(characterValue)}
634
+ onValueChange={(value) => handleVoiceModelChange(value, field.name)}
635
+ >
636
+ <SelectTrigger>
637
+ <SelectValue
638
+ placeholder={
639
+ field.name.includes('voice.model') && isLoadingVoices
640
+ ? 'Loading voice models...'
641
+ : 'Select a voice model'
642
+ }
643
+ />
644
+ </SelectTrigger>
645
+ <SelectContent>
646
+ {field.name === 'settings.voice.model' ? (
647
+ <>
648
+ <SelectGroup>
649
+ <SelectItem value="none">No Voice</SelectItem>
650
+ </SelectGroup>
651
+
652
+ <SelectSeparator />
653
+
654
+ <SelectGroup>
655
+ <SelectLabel>OpenAI Voices</SelectLabel>
656
+ {openAIVoiceModels.map((model) => (
657
+ <SelectItem key={model.value} value={model.value}>
658
+ {model.label.replace('OpenAI - ', '')}
659
+ </SelectItem>
660
+ ))}
661
+ </SelectGroup>
662
+
663
+ <SelectSeparator />
664
+
665
+ <SelectGroup>
666
+ <SelectLabel>ElevenLabs Voices</SelectLabel>
667
+ {/* Show default ElevenLabs voices from config */}
668
+ {elevenLabsVoiceModels.map((model) => (
669
+ <SelectItem key={model.value} value={model.value}>
670
+ {model.label.replace('ElevenLabs - ', '')}
671
+ </SelectItem>
672
+ ))}
673
+ {/* Show custom ElevenLabs voices if available */}
674
+ {elevenlabsVoices &&
675
+ elevenlabsVoices.length > 0 &&
676
+ elevenlabsVoices.map((voice) => (
677
+ <SelectItem key={voice.value} value={voice.value}>
678
+ {voice.label.replace('ElevenLabs - ', '')}
679
+ </SelectItem>
680
+ ))}
681
+ </SelectGroup>
682
+ </>
683
+ ) : (
684
+ field.options?.map((option) => (
685
+ <SelectItem key={option.value} value={option.value}>
686
+ {option.label}
687
+ </SelectItem>
688
+ ))
689
+ )}
690
+ </SelectContent>
691
+ </Select>
692
+ ) : (
693
+ <Input
694
+ id={field.name}
695
+ name={field.name}
696
+ type={field.fieldType}
697
+ value={field.getValue(characterValue)}
698
+ onChange={handleChange}
699
+ />
700
+ )}
701
+
702
+ {field.description && (
703
+ <p className="text-xs text-muted-foreground mt-1">{field.description}</p>
704
+ )}
705
+ </div>
706
+ );
707
+
708
+ const renderArrayField = (field: ArrayField) => (
709
+ <div key={field.path} className="w-full">
710
+ <Label htmlFor={field.path} className="text-sm font-normal block mb-2">
711
+ {field.title}
712
+ {field.path in FIELD_REQUIREMENTS &&
713
+ (FIELD_REQUIREMENTS as Record<string, FIELD_REQUIREMENT_TYPE>)[field.path] ===
714
+ FIELD_REQUIREMENT_TYPE.REQUIRED && <span className="text-destructive ml-1">*</span>}
715
+ </Label>
716
+
717
+ <ArrayInput
718
+ data={field.getData(characterValue)}
719
+ onChange={(newData) => updateArray(field.path, newData)}
720
+ />
721
+
722
+ {field.description && (
723
+ <p className="text-xs text-muted-foreground mt-1">{field.description}</p>
724
+ )}
725
+ </div>
726
+ );
727
+
728
+ const handleExportJSON = () => {
729
+ exportCharacterAsJson(characterValue, toast);
730
+ };
731
+
732
+ function isV1Character(char: unknown): char is V1Character {
733
+ return (
734
+ typeof char === 'object' &&
735
+ char !== null &&
736
+ ('lore' in char || 'clients' in char || 'modelProvider' in char)
737
+ );
738
+ }
739
+
740
+ const handleImportJSON = async (event: React.ChangeEvent<HTMLInputElement>) => {
741
+ const file = event.target.files?.[0];
742
+ if (!file) return;
743
+
744
+ try {
745
+ const text = await file.text();
746
+ let json: Character = JSON.parse(text);
747
+
748
+ if (isV1Character(json)) {
749
+ json = convertCharacter(json);
750
+ }
751
+
752
+ // Check for required fields using FIELD_REQUIREMENTS
753
+ const missingFields = (
754
+ Object.keys(FIELD_REQUIREMENTS) as Array<keyof typeof FIELD_REQUIREMENTS>
755
+ ).filter((field) => {
756
+ if (FIELD_REQUIREMENTS[field] !== FIELD_REQUIREMENT_TYPE.REQUIRED) return false;
757
+
758
+ // Handle nested fields like style.all
759
+ const parts = field.split('.');
760
+ let current: any = json;
761
+
762
+ for (const part of parts) {
763
+ current = current?.[part];
764
+ if (current === undefined) return true; // field missing
765
+ }
766
+
767
+ return false;
768
+ });
769
+
770
+ if (missingFields.length > 0) {
771
+ toast({
772
+ title: 'Import Failed',
773
+ description: `Missing required fields: ${missingFields.join(', ')}`,
774
+ variant: 'destructive',
775
+ });
776
+ return;
777
+ }
778
+
779
+ if (setCharacterValue.importAgent) {
780
+ setCharacterValue.importAgent(json);
781
+ } else {
782
+ console.warn('Missing importAgent method');
783
+ }
784
+
785
+ toast({
786
+ title: 'Agent Imported',
787
+ description: 'Agent data has been successfully loaded.',
788
+ });
789
+ } catch (error) {
790
+ toast({
791
+ title: 'Import Failed',
792
+ description: error instanceof Error ? error.message : 'Invalid JSON file',
793
+ variant: 'destructive',
794
+ });
795
+ } finally {
796
+ event.target.value = '';
797
+ }
798
+ };
799
+
800
+ // File input ref for import functionality
801
+ const fileInputRef = useRef<HTMLInputElement>(null);
802
+
803
+ const handleImportClick = () => {
804
+ fileInputRef.current?.click();
805
+ };
806
+
807
+ // Define stop/delete options (only if both are available)
808
+ const stopDeleteOptions = useMemo(() => {
809
+ const options = [];
810
+
811
+ if (onStopAgent) {
812
+ options.push({
813
+ label: 'Stop Agent',
814
+ description: 'Stop running',
815
+ onClick: onStopAgent,
816
+ });
817
+ }
818
+
819
+ if (onDelete) {
820
+ options.push({
821
+ label: 'Delete Agent',
822
+ description: 'Delete permanently',
823
+ onClick: () => onDelete(),
824
+ });
825
+ }
826
+
827
+ return options;
828
+ }, [onStopAgent, onDelete]);
829
+
830
+ /**
831
+ * Handle template selection
832
+ */
833
+ const handleTemplateChange = useCallback(
834
+ (templateId: string) => {
835
+ setSelectedTemplate(templateId);
836
+
837
+ // If "None" is selected, reset to blank form if reset function is available
838
+ if (templateId === 'none' && onReset) {
839
+ onReset();
840
+ return;
841
+ }
842
+
843
+ // Get the template data
844
+ const template = getTemplateById(templateId);
845
+ if (template && setCharacterValue.importAgent) {
846
+ // Use the importAgent function to set all template values at once
847
+ setCharacterValue.importAgent(template.template as Character);
848
+ // Notify parent of template change
849
+ onTemplateChange?.();
850
+ }
851
+ },
852
+ [onReset, setCharacterValue, onTemplateChange]
853
+ );
854
+
855
+ // Create all tabs data with better short labels
856
+ const allTabs = [
857
+ ...AGENT_FORM_SCHEMA.map((section) => ({
858
+ value: section.sectionValue,
859
+ label: section.sectionTitle,
860
+ shortLabel: section.sectionTitle.split(' ')[0], // Use first word for mobile
861
+ })),
862
+ ...customComponents.map((component) => ({
863
+ value: `custom-${component.name}`,
864
+ label: component.name,
865
+ shortLabel: (component as any).shortLabel || component.name.split(' ')[0], // Use first word
866
+ })),
867
+ ];
868
+
869
+ return (
870
+ <div ref={containerRef} className="w-full max-w-full mx-auto p-6 sm:p-8 h-full overflow-y-auto">
871
+ {(title || description) && (
872
+ <div className="mb-8">
873
+ {title && <h1 className="text-2xl font-semibold mb-2">{title}</h1>}
874
+ {description && (
875
+ <p className="text-sm text-muted-foreground whitespace-pre-line">{description}</p>
876
+ )}
877
+ </div>
878
+ )}
879
+
880
+ {/* Template Selector */}
881
+ <div className="mb-8">
882
+ <Label htmlFor="template-selector" className="text-sm">
883
+ Start with a template
884
+ </Label>
885
+ <Select value={selectedTemplate} onValueChange={handleTemplateChange}>
886
+ <SelectTrigger className="w-full mt-1">
887
+ <SelectValue placeholder="None (blank start)" />
888
+ </SelectTrigger>
889
+ <SelectContent>
890
+ {agentTemplates.map((template) => (
891
+ <SelectItem key={template.id} value={template.id}>
892
+ <TooltipProvider>
893
+ <Tooltip>
894
+ <TooltipTrigger asChild>
895
+ <span className="w-full text-left">{template.label}</span>
896
+ </TooltipTrigger>
897
+ <TooltipContent side="right">
898
+ <p className="max-w-xs">{template.description}</p>
899
+ </TooltipContent>
900
+ </Tooltip>
901
+ </TooltipProvider>
902
+ </SelectItem>
903
+ ))}
904
+ </SelectContent>
905
+ </Select>
906
+ </div>
907
+
908
+ <form onSubmit={handleFormSubmit}>
909
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
910
+ <div className="relative mb-4">
911
+ {/* Scroll button left */}
912
+ {showLeftScroll && (
913
+ <Button
914
+ type="button"
915
+ variant="ghost"
916
+ size="sm"
917
+ className="absolute left-0 top-1/2 -translate-y-1/2 z-10 h-8 w-8 p-0 bg-background/80 backdrop-blur-sm shadow-md"
918
+ onClick={() => scrollTabs('left')}
919
+ >
920
+ <ChevronLeft className="h-4 w-4" />
921
+ </Button>
922
+ )}
923
+
924
+ {/* Tabs container */}
925
+ <div ref={tabsContainerRef} className="overflow-x-auto scrollbar-hide">
926
+ <TabsList className="inline-flex h-10 items-center justify-start text-muted-foreground w-full">
927
+ {allTabs.map((tab) => (
928
+ <TabsTrigger
929
+ key={tab.value}
930
+ value={tab.value}
931
+ className={cn(
932
+ 'whitespace-nowrap',
933
+ !showLabels && 'px-2 text-xs' // Smaller padding and text on mobile
934
+ )}
935
+ >
936
+ {showLabels ? tab.label : tab.shortLabel}
937
+ </TabsTrigger>
938
+ ))}
939
+ </TabsList>
940
+ </div>
941
+
942
+ {/* Scroll button right */}
943
+ {showRightScroll && (
944
+ <Button
945
+ type="button"
946
+ variant="ghost"
947
+ size="sm"
948
+ className="absolute right-0 top-1/2 -translate-y-1/2 z-10 h-8 w-8 p-0 bg-background/80 backdrop-blur-sm shadow-md"
949
+ onClick={() => scrollTabs('right')}
950
+ >
951
+ <ChevronRight className="h-4 w-4" />
952
+ </Button>
953
+ )}
954
+ </div>
955
+
956
+ <div className="max-h-[100vh] overflow-y-auto pb-4 px-1">
957
+ {AGENT_FORM_SCHEMA.map((section) => (
958
+ <TabsContent
959
+ key={section.sectionValue}
960
+ value={section.sectionValue}
961
+ className="space-y-6 mt-0 focus:outline-none"
962
+ >
963
+ {section.sectionType === SECTION_TYPE.INPUT
964
+ ? (section.fields as InputField[]).map(renderInputField)
965
+ : (section.fields as ArrayField[]).map(renderArrayField)}
966
+ </TabsContent>
967
+ ))}
968
+ {customComponents.map((component) => (
969
+ <TabsContent
970
+ key={`custom-${component.name}`}
971
+ value={`custom-${component.name}`}
972
+ className="mt-0 focus:outline-none"
973
+ >
974
+ <div className="h-full">{component.component}</div>
975
+ </TabsContent>
976
+ ))}
977
+ </div>
978
+ </Tabs>
979
+
980
+ <div className="flex items-center justify-between w-full mt-6">
981
+ {/* Three-dot menu button on the left */}
982
+ <DropdownMenu>
983
+ <DropdownMenuTrigger asChild>
984
+ <Button type="button" variant="outline" size="icon" className="flex-shrink-0 p-2.5">
985
+ <MoreVertical className="h-4 w-4" />
986
+ <span className="sr-only">More options</span>
987
+ </Button>
988
+ </DropdownMenuTrigger>
989
+ <DropdownMenuContent align="start" side="top">
990
+ <DropdownMenuItem onClick={handleImportClick}>
991
+ <ArrowDownToLine className="h-4 w-4 mr-2" />
992
+ Import
993
+ </DropdownMenuItem>
994
+ <DropdownMenuItem onClick={handleExportJSON}>
995
+ <ArrowUpFromLine className="h-4 w-4 mr-2" />
996
+ Export
997
+ </DropdownMenuItem>
998
+ {stopDeleteOptions.length > 0 && <DropdownMenuSeparator />}
999
+ {stopDeleteOptions.length > 0 &&
1000
+ stopDeleteOptions.map((option) => {
1001
+ const isStopAction = option.label === 'Stop Agent';
1002
+ const isDeleteAction = option.label === 'Delete Agent';
1003
+ const isLoading = isStopAction ? isStopping : isDeleteAction ? isDeleting : false;
1004
+
1005
+ return (
1006
+ <DropdownMenuItem
1007
+ key={option.label}
1008
+ onClick={option.onClick}
1009
+ disabled={isLoading}
1010
+ className={
1011
+ isDeleteAction
1012
+ ? 'text-destructive focus:text-destructive hover:bg-red-50 dark:hover:bg-red-950/50'
1013
+ : ''
1014
+ }
1015
+ >
1016
+ {isLoading ? (
1017
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
1018
+ ) : isStopAction ? (
1019
+ <StopCircle className="h-4 w-4 mr-2" />
1020
+ ) : (
1021
+ <Trash className="h-4 w-4 mr-2" />
1022
+ )}
1023
+ {isLoading ? `${isStopAction ? 'Stopping' : 'Deleting'}...` : option.label}
1024
+ </DropdownMenuItem>
1025
+ );
1026
+ })}
1027
+ </DropdownMenuContent>
1028
+ </DropdownMenu>
1029
+
1030
+ {/* Reset and Save buttons on the right with gap */}
1031
+ <div className="flex items-center gap-3">
1032
+ <TooltipProvider>
1033
+ <Tooltip>
1034
+ <TooltipTrigger asChild>
1035
+ <Button
1036
+ type="button"
1037
+ variant="outline"
1038
+ onClick={() => {
1039
+ onReset?.();
1040
+ }}
1041
+ >
1042
+ <RotateCcw className="h-4 w-4" />
1043
+ <span className="ml-2">Reset</span>
1044
+ </Button>
1045
+ </TooltipTrigger>
1046
+ <TooltipContent>
1047
+ <p>Reset all form fields to their original values</p>
1048
+ </TooltipContent>
1049
+ </Tooltip>
1050
+ </TooltipProvider>
1051
+
1052
+ <TooltipProvider>
1053
+ <Tooltip>
1054
+ <TooltipTrigger asChild>
1055
+ <Button type="submit" disabled={isSubmitting} className="agent-form-submit">
1056
+ {isSubmitting ? (
1057
+ <>
1058
+ <Loader2 className="h-4 w-4 animate-spin" />
1059
+ <span className="ml-2">Saving...</span>
1060
+ </>
1061
+ ) : (
1062
+ <>
1063
+ <Save className="h-4 w-4" />
1064
+ <span className="ml-2">Save</span>
1065
+ </>
1066
+ )}
1067
+ </Button>
1068
+ </TooltipTrigger>
1069
+ <TooltipContent>
1070
+ <p>Save all changes to the agent configuration</p>
1071
+ </TooltipContent>
1072
+ </Tooltip>
1073
+ </TooltipProvider>
1074
+ </div>
1075
+
1076
+ {/* Hidden file input for import */}
1077
+ <input
1078
+ ref={fileInputRef}
1079
+ type="file"
1080
+ accept=".json"
1081
+ onChange={handleImportJSON}
1082
+ className="hidden"
1083
+ />
1084
+ </div>
1085
+ </form>
1086
+
1087
+ {/* Missing Secrets Warning Dialog */}
1088
+ <MissingSecretsDialog
1089
+ open={showMissingSecretsDialog}
1090
+ onOpenChange={setShowMissingSecretsDialog}
1091
+ missingSecrets={(() => {
1092
+ let missingSecretNames: string[] = [];
1093
+
1094
+ // If secret panel is mounted, use it (has most up-to-date data)
1095
+ if (secretPanelRef?.current) {
1096
+ const validation = secretPanelRef.current.validateSecrets();
1097
+ missingSecretNames = validation.missingSecrets;
1098
+ } else {
1099
+ // Secret panel not mounted - calculate based on character value
1100
+ const secretsObj = characterValue.settings?.secrets;
1101
+ const currentSecrets =
1102
+ secretsObj && typeof secretsObj === 'object' && !Array.isArray(secretsObj)
1103
+ ? (secretsObj as Record<string, any>)
1104
+ : {};
1105
+
1106
+ missingSecretNames = requiredSecrets
1107
+ .filter((secret) => {
1108
+ const value = currentSecrets[secret.name];
1109
+ // Check agent-specific secret
1110
+ if (value && typeof value === 'string' && value.trim() !== '') {
1111
+ return false;
1112
+ }
1113
+ // Check global environment
1114
+ const globalValue = globalEnvs[secret.name];
1115
+ if (globalValue && globalValue.trim() !== '') {
1116
+ return false;
1117
+ }
1118
+ return true;
1119
+ })
1120
+ .map((secret) => secret.name);
1121
+ }
1122
+
1123
+ // Map secret names to full details
1124
+ return missingSecretNames.map((secretName) => {
1125
+ const reqSecret = requiredSecrets.find((s) => s.name === secretName);
1126
+ return {
1127
+ name: secretName,
1128
+ plugin: reqSecret?.plugin,
1129
+ description: reqSecret?.description,
1130
+ };
1131
+ });
1132
+ })()}
1133
+ onConfirm={handleConfirmSaveWithMissingSecrets}
1134
+ onCancel={handleCancelSaveWithMissingSecrets}
1135
+ />
1136
+ </div>
1137
+ );
1138
+ }