agent-sheet.tsx
1 'use client' 2 3 import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { selectActiveSessionId } from '@/stores/slices/session-slice' 6 import { createAgent, updateAgent, deleteAgent } from '@/lib/agents' 7 import { api } from '@/lib/app/api-client' 8 import { fetchProviderModelDiscovery } from '@/lib/provider-model-discovery-client' 9 import { sleep } from '@/lib/shared-utils' 10 import { BottomSheet } from '@/components/shared/bottom-sheet' 11 import { toast } from 'sonner' 12 import { ModelCombobox } from '@/components/shared/model-combobox' 13 import type { ProviderType, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types' 14 import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions' 15 import { MCP_INJECTION_PROVIDER_IDS, NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS, WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets' 16 import { isOrchestratorProviderEligible } from '@/lib/orchestrator-config' 17 import { AgentAvatar } from './agent-avatar' 18 import { AgentPickerList } from '@/components/shared/agent-picker-list' 19 import { randomSoul } from '@/lib/soul-suggestions' 20 import { copyTextToClipboard } from '@/lib/clipboard' 21 import { SectionLabel } from '@/components/shared/section-label' 22 import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section' 23 import { SoulLibraryPicker } from './soul-library-picker' 24 import { HintTip } from '@/components/shared/hint-tip' 25 import { StatusDot } from '@/components/ui/status-dot' 26 import { resolveStoredOllamaMode } from '@/lib/ollama-mode' 27 import { errorMessage } from '@/lib/shared-utils' 28 import { getDefaultAgentToolIds } from '@/lib/agent-default-tools' 29 import { getEnabledExtensionIds, getEnabledToolIds } from '@/lib/capability-selection' 30 import { buildAgentSelectableProviders, resolveAgentSelectableProviderCredentials } from '@/lib/agent-provider-options' 31 import { AgentSocialSettings } from '@/features/swarmfeed/agent-social-settings' 32 import { AgentMarketplaceSettings } from '@/features/swarmdock/agent-marketplace-settings' 33 34 const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const 35 const FALLBACK_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb' 36 const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([ 37 'openai', 38 'openrouter', 39 'anthropic', 40 'google', 41 'deepseek', 42 'groq', 43 'together', 44 'mistral', 45 'xai', 46 'fireworks', 47 'nebius', 48 'deepinfra', 49 'hermes', 50 'ollama', 51 ]) 52 const CONNECTION_TEST_TIMEOUT_MS = 40_000 53 type AgentProviderId = string 54 55 function SectionCard({ 56 title, 57 description, 58 action, 59 children, 60 className = '', 61 }: { 62 title: string 63 description?: string 64 action?: React.ReactNode 65 children: React.ReactNode 66 className?: string 67 }) { 68 return ( 69 <section className={`mb-8 rounded-[20px] border border-white/[0.06] bg-surface/70 p-5 sm:p-6 ${className}`}> 70 <div className="mb-5 flex items-start justify-between gap-4"> 71 <div> 72 <h3 className="font-display text-[17px] font-700 tracking-[-0.02em] text-text">{title}</h3> 73 {description && ( 74 <p className="mt-1 text-[13px] leading-[1.6] text-text-3/75">{description}</p> 75 )} 76 </div> 77 {action} 78 </div> 79 {children} 80 </section> 81 ) 82 } 83 84 function formatHbDuration(sec: number): string { 85 if (sec >= 3600) { 86 const h = Math.floor(sec / 3600) 87 const m = Math.floor((sec % 3600) / 60) 88 return m > 0 ? `${h}h${m}m` : `${h}h` 89 } 90 if (sec >= 60) return `${Math.floor(sec / 60)}m` 91 return `${sec}s` 92 } 93 94 /** Parse a stored heartbeatInterval string or heartbeatIntervalSec number to a select-friendly string of seconds */ 95 function parseDurationToSec(interval: string | number | null | undefined, intervalSec: number | null | undefined): string { 96 if (intervalSec != null && Number.isFinite(intervalSec) && intervalSec > 0) { 97 // Snap to nearest preset if close, otherwise use raw value 98 const closest = HB_PRESETS.find((p) => p === Math.round(intervalSec)) 99 if (closest) return String(closest) 100 } 101 if (typeof interval === 'number' && Number.isFinite(interval) && interval > 0) { 102 return String(Math.round(interval)) 103 } 104 if (interval != null && typeof interval === 'string' && interval.trim()) { 105 const t = interval.trim().toLowerCase() 106 const n = Number(t) 107 if (Number.isFinite(n) && n > 0) return String(Math.round(n)) 108 const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/) 109 if (m && (m[1] || m[2] || m[3])) { 110 const total = (m[1] ? parseInt(m[1]) * 3600 : 0) + (m[2] ? parseInt(m[2]) * 60 : 0) + (m[3] ? parseInt(m[3]) : 0) 111 if (total > 0) return String(total) 112 } 113 } 114 return '' // default 115 } 116 117 function formatIdentityList(value: string[] | null | undefined): string { 118 return Array.isArray(value) ? value.join('\n') : '' 119 } 120 121 function parseIdentityList(value: string): string[] { 122 const seen = new Set<string>() 123 return value 124 .split('\n') 125 .map((line) => line.replace(/\s+/g, ' ').trim()) 126 .filter((line) => { 127 if (!line) return false 128 const key = line.toLowerCase() 129 if (seen.has(key)) return false 130 seen.add(key) 131 return true 132 }) 133 } 134 135 function formatGatewayTagList(value: string[] | null | undefined): string { 136 return Array.isArray(value) ? value.join(', ') : '' 137 } 138 139 function parseGatewayTagList(value: string): string[] { 140 const seen = new Set<string>() 141 return value 142 .split(/[,\n]/) 143 .map((entry) => entry.trim()) 144 .filter((entry) => { 145 if (!entry) return false 146 const key = entry.toLowerCase() 147 if (seen.has(key)) return false 148 seen.add(key) 149 return true 150 }) 151 } 152 153 export function AgentSheet() { 154 const open = useAppStore((s) => s.agentSheetOpen) 155 const setOpen = useAppStore((s) => s.setAgentSheetOpen) 156 const editingId = useAppStore((s) => s.editingAgentId) 157 const setEditingId = useAppStore((s) => s.setEditingAgentId) 158 const agents = useAppStore((s) => s.agents) 159 const loadAgents = useAppStore((s) => s.loadAgents) 160 const updateAgentInStore = useAppStore((s) => s.updateAgentInStore) 161 const activeSessionId = useAppStore(selectActiveSessionId) 162 const currentSession = useAppStore((s) => { 163 const id = selectActiveSessionId(s) 164 return id ? s.sessions[id] : null 165 }) 166 const refreshSession = useAppStore((s) => s.refreshSession) 167 const projects = useAppStore((s) => s.projects) 168 const loadProjects = useAppStore((s) => s.loadProjects) 169 const providers = useAppStore((s) => s.providers) 170 const loadProviders = useAppStore((s) => s.loadProviders) 171 const providerConfigs = useAppStore((s) => s.providerConfigs) 172 const loadProviderConfigs = useAppStore((s) => s.loadProviderConfigs) 173 const gatewayProfiles = useAppStore((s) => s.gatewayProfiles) 174 const loadGatewayProfiles = useAppStore((s) => s.loadGatewayProfiles) 175 const credentials = useAppStore((s) => s.credentials) 176 const loadCredentials = useAppStore((s) => s.loadCredentials) 177 const appSettings = useAppStore((s) => s.appSettings) 178 const loadSettings = useAppStore((s) => s.loadSettings) 179 const dynamicSkills = useAppStore((s) => s.skills) 180 const mcpServers = useAppStore((s) => s.mcpServers) 181 const loadSkills = useAppStore((s) => s.loadSkills) 182 const loadMcpServersAction = useAppStore((s) => s.loadMcpServers) 183 const [claudeSkills, setClaudeSkills] = useState<ClaudeSkill[]>([]) 184 const [claudeSkillsLoading, setClaudeSkillsLoading] = useState(false) 185 const loadClaudeSkills = async () => { 186 setClaudeSkillsLoading(true) 187 try { 188 const skills = await api<ClaudeSkill[]>('GET', '/claude-skills') 189 setClaudeSkills(skills) 190 } catch { /* ignore */ } 191 finally { setClaudeSkillsLoading(false) } 192 } 193 194 const [name, setName] = useState('') 195 const [description, setDescription] = useState('') 196 const [soul, setSoul] = useState('') 197 const [soulInitial, setSoulInitial] = useState('') 198 const [soulSaveState, setSoulSaveState] = useState<'idle' | 'saved'>('idle') 199 const [systemPrompt, setSystemPrompt] = useState('') 200 const [provider, setProvider] = useState<AgentProviderId>('claude-cli') 201 const [model, setModel] = useState('') 202 const [credentialId, setCredentialId] = useState<string | null>(null) 203 const [apiEndpoint, setApiEndpoint] = useState<string | null>(null) 204 const [gatewayProfileId, setGatewayProfileId] = useState<string | null>(null) 205 const [preferredGatewayTagsText, setPreferredGatewayTagsText] = useState('') 206 const [preferredGatewayUseCase, setPreferredGatewayUseCase] = useState('') 207 const [routingStrategy, setRoutingStrategy] = useState<AgentRoutingStrategy>('single') 208 const [routingTargets, setRoutingTargets] = useState<AgentRoutingTarget[]>([]) 209 const [role, setRole] = useState<'worker' | 'coordinator'>('worker') 210 const [delegationEnabled, setDelegationEnabled] = useState(false) 211 const [delegationTargetMode, setDelegationTargetMode] = useState<'all' | 'selected'>('all') 212 const [delegationTargetAgentIds, setDelegationTargetAgentIds] = useState<string[]>([]) 213 const [tools, setTools] = useState<string[]>([]) 214 // Scoped tool access is the default for new agents (cuts ~3 k input tokens 215 // per turn). Existing agents with no toolAccessMode field persisted stay 216 // universal server-side for backward compat; the new-agent setup path 217 // below also explicitly writes 'scoped' so it persists on save. 218 const [toolAccessMode, setToolAccessMode] = useState<'universal' | 'scoped'>('scoped') 219 const [extensions, setExtensions] = useState<string[]>([]) 220 const [enabledExtensionIds, setEnabledExtensionIds] = useState<Set<string> | null>(null) 221 const [skills, setSkills] = useState<string[]>([]) 222 const [skillIds, setSkillIds] = useState<string[]>([]) 223 const [mcpServerIds, setMcpServerIds] = useState<string[]>([]) 224 const [mcpDisabledTools, setMcpDisabledTools] = useState<string[]>([]) 225 const [mcpTools, setMcpTools] = useState<Record<string, { name: string; description: string }[]>>({}) 226 const [mcpToolsLoading, setMcpToolsLoading] = useState(false) 227 const [fallbackCredentialIds, setFallbackCredentialIds] = useState<string[]>([]) 228 const [capabilities, setCapabilities] = useState<string[]>([]) 229 const [capInput, setCapInput] = useState('') 230 const [ollamaMode, setOllamaMode] = useState<'local' | 'cloud'>('local') 231 const [openclawEnabled, setOpenclawEnabled] = useState(false) 232 const [projectId, setProjectId] = useState<string | undefined>(undefined) 233 const [avatarSeed, setAvatarSeed] = useState('') 234 const [avatarUrl, setAvatarUrl] = useState<string | null>(null) 235 const [uploading, setUploading] = useState(false) 236 const [thinkingLevel, setThinkingLevel] = useState<'' | 'minimal' | 'low' | 'medium' | 'high'>('') 237 const [memoryScopeMode, setMemoryScopeMode] = useState<'auto' | 'all' | 'global' | 'agent' | 'session' | 'project'>('auto') 238 const [memoryTierPreference, setMemoryTierPreference] = useState<'working' | 'durable' | 'archive' | 'blended'>('blended') 239 const [proactiveMemory, setProactiveMemory] = useState(true) 240 const [autoDraftSkillSuggestions, setAutoDraftSkillSuggestions] = useState(true) 241 const [autoRecovery, setAutoRecovery] = useState(false) 242 const [disabled, setDisabled] = useState(false) 243 const [filesystemScope, setFilesystemScope] = useState<'workspace' | 'machine'>('workspace') 244 const [voiceId, setVoiceId] = useState('') 245 const [heartbeatEnabled, setHeartbeatEnabled] = useState(false) 246 const [heartbeatIntervalSec, setHeartbeatIntervalSec] = useState('') // '' = default (30m) 247 const [heartbeatModel, setHeartbeatModel] = useState('') 248 const [heartbeatPrompt, setHeartbeatPrompt] = useState('') 249 const [dreamEnabled, setDreamEnabled] = useState(false) 250 const [dreamCooldownMinutes, setDreamCooldownMinutes] = useState('360') 251 const [dreamTier2Enabled, setDreamTier2Enabled] = useState(true) 252 const [orchestratorEnabled, setOrchestratorEnabled] = useState(false) 253 const [orchestratorMission, setOrchestratorMission] = useState('') 254 const [orchestratorWakeInterval, setOrchestratorWakeInterval] = useState('5m') 255 const [orchestratorGovernance, setOrchestratorGovernance] = useState<'autonomous' | 'approval-required' | 'notify-only'>('autonomous') 256 const [orchestratorMaxCyclesPerDay, setOrchestratorMaxCyclesPerDay] = useState<string>('') 257 const [sessionResetMode, setSessionResetMode] = useState<'' | 'idle' | 'daily' | 'isolated'>('') 258 const [sessionIdleTimeoutSec, setSessionIdleTimeoutSec] = useState('') 259 const [sessionMaxAgeSec, setSessionMaxAgeSec] = useState('') 260 const [sessionDailyResetAt, setSessionDailyResetAt] = useState('') 261 const [sessionResetTimezone, setSessionResetTimezone] = useState('') 262 const [identityPersonaLabel, setIdentityPersonaLabel] = useState('') 263 const [identitySelfSummary, setIdentitySelfSummary] = useState('') 264 const [identityRelationshipSummary, setIdentityRelationshipSummary] = useState('') 265 const [identityToneStyle, setIdentityToneStyle] = useState('') 266 const [identityBoundariesText, setIdentityBoundariesText] = useState('') 267 const [identityContinuityNotesText, setIdentityContinuityNotesText] = useState('') 268 const [budgetEnabled, setBudgetEnabled] = useState(false) 269 const [hourlyBudget, setHourlyBudget] = useState('') 270 const [dailyBudget, setDailyBudget] = useState('') 271 const [monthlyBudget, setMonthlyBudget] = useState('') 272 const [budgetAction, setBudgetAction] = useState<'warn' | 'block'>('warn') 273 const [addingKey, setAddingKey] = useState(false) 274 const [newKeyName, setNewKeyName] = useState('') 275 const [newKeyValue, setNewKeyValue] = useState('') 276 const [savingKey, setSavingKey] = useState(false) 277 278 // Test connection state 279 const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle') 280 const [testMessage, setTestMessage] = useState('') 281 const [testErrorCode, setTestErrorCode] = useState<string | null>(null) 282 const [testDeviceId, setTestDeviceId] = useState<string | null>(null) 283 const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null) 284 const [configCopied, setConfigCopied] = useState(false) 285 286 const soulFileRef = useRef<HTMLInputElement>(null) 287 const [soulLibraryOpen, setSoulLibraryOpen] = useState(false) 288 const promptFileRef = useRef<HTMLInputElement>(null) 289 const importFileRef = useRef<HTMLInputElement>(null) 290 const lastAutoSyncedModelsKeyRef = useRef<string | null>(null) 291 const skipAutoModelRef = useRef(false) 292 const [showAdvancedSettings, setShowAdvancedSettings] = useState(false) 293 294 const handleFileUpload = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => { 295 const file = e.target.files?.[0] 296 if (!file) return 297 const reader = new FileReader() 298 reader.onload = (ev) => setter(ev.target?.result as string) 299 reader.readAsText(file) 300 e.target.value = '' 301 } 302 303 const agentSelectableProviders = useMemo( 304 () => buildAgentSelectableProviders(providers, providerConfigs), 305 [providers, providerConfigs], 306 ) 307 const currentProvider = agentSelectableProviders.find((p) => p.id === provider) 308 const providerCredentials = useMemo( 309 () => resolveAgentSelectableProviderCredentials(provider, credentials, providerConfigs), 310 [credentials, provider, providerConfigs], 311 ) 312 const openclawCredentials = Object.values(credentials).filter((c) => c.provider === 'openclaw') 313 const openclawGatewayProfiles = gatewayProfiles.filter((item) => item.provider === 'openclaw') 314 const setAgentPrefill = useAppStore((s) => s.setAgentPrefill) 315 const editing = editingId ? agents[editingId] : null 316 const hasNativeCapabilities = NATIVE_CAPABILITY_PROVIDER_IDS.has(provider) 317 const globalVoiceId = typeof appSettings.elevenLabsVoiceId === 'string' ? appSettings.elevenLabsVoiceId.trim() : '' 318 const agentVoiceId = voiceId.trim() 319 const elevenLabsConfigured = appSettings.elevenLabsApiKeyConfigured === true 320 const voiceControlsAvailable = elevenLabsConfigured || appSettings.elevenLabsEnabled === true || !!globalVoiceId || !!agentVoiceId 321 const voicePlaybackEnabled = appSettings.elevenLabsEnabled === true 322 const effectiveVoiceId = agentVoiceId || globalVoiceId || FALLBACK_ELEVENLABS_VOICE_ID 323 const effectiveVoiceSource = agentVoiceId 324 ? 'Agent override' 325 : globalVoiceId 326 ? 'Global default' 327 : 'Built-in fallback' 328 const syncLiveProviderModels = useCallback(async ( 329 providerId: string, 330 nextCredentialId: string | null, 331 nextEndpoint: string | null, 332 nextOllamaMode: 'local' | 'cloud', 333 force = false, 334 ): Promise<{ synced: boolean; models: string[] } | null> => { 335 if (openclawEnabled) return null 336 if (!AUTO_SYNC_MODEL_PROVIDER_IDS.has(providerId as ProviderType)) return null 337 const providerInfo = agentSelectableProviders.find((item) => item.id === providerId) 338 if (!providerInfo?.supportsModelDiscovery) return null 339 340 const result = await fetchProviderModelDiscovery({ 341 providerId, 342 credentialId: nextCredentialId, 343 endpoint: nextEndpoint, 344 ollamaMode: providerId === 'ollama' ? nextOllamaMode : null, 345 force, 346 }) 347 348 if (!result.ok || result.models.length === 0) return { synced: false, models: result.models } 349 350 const sameModels = providerInfo.models.length === result.models.length 351 && providerInfo.models.every((item, index) => item === result.models[index]) 352 353 if (!sameModels) { 354 await api('PUT', `/providers/${providerId}/models`, { models: result.models }) 355 await loadProviders() 356 } 357 358 setModel((currentModel) => currentModel.trim() || result.models[0] || '') 359 return { synced: !sameModels, models: result.models } 360 }, [agentSelectableProviders, loadProviders, openclawEnabled]) 361 362 const providerNeedsKey = !editing && ( 363 (currentProvider?.requiresApiKey && providerCredentials.length === 0 && !addingKey) || 364 (provider === 'ollama' && ollamaMode === 'cloud' && providerCredentials.length === 0 && !addingKey) 365 ) 366 367 useEffect(() => { 368 if (!open) { 369 lastAutoSyncedModelsKeyRef.current = null 370 return 371 } 372 if (openclawEnabled) return 373 if (!AUTO_SYNC_MODEL_PROVIDER_IDS.has(provider as ProviderType)) return 374 if (!currentProvider?.supportsModelDiscovery) return 375 376 const requiresCredential = currentProvider.requiresApiKey || (provider === 'ollama' && ollamaMode === 'cloud') 377 if (requiresCredential && !credentialId) return 378 379 const syncKey = `${provider}::${credentialId || ''}::${apiEndpoint?.trim() || ''}::${provider === 'ollama' ? ollamaMode : ''}` 380 if (lastAutoSyncedModelsKeyRef.current === syncKey) return 381 lastAutoSyncedModelsKeyRef.current = syncKey 382 383 void syncLiveProviderModels(provider, credentialId, apiEndpoint, ollamaMode, false).catch(() => {}) 384 }, [apiEndpoint, credentialId, currentProvider, ollamaMode, open, openclawEnabled, provider, syncLiveProviderModels]) 385 386 useEffect(() => { 387 if (open) { 388 loadSettings() 389 loadProviders() 390 loadProviderConfigs() 391 loadGatewayProfiles() 392 loadCredentials() 393 loadSkills() 394 loadMcpServersAction() 395 loadProjects() 396 loadClaudeSkills() 397 // Fetch enabled extension IDs so we can filter tool toggles 398 api<{ enabledExtensionIds: string[] }>('GET', '/extensions/builtins') 399 .then((res) => { if (res?.enabledExtensionIds) setEnabledExtensionIds(new Set(res.enabledExtensionIds)) }) 400 .catch(() => {}) 401 setTestStatus('idle') 402 setTestMessage('') 403 setShowAdvancedSettings(false) 404 if (editing) { 405 setName(editing.name) 406 setDescription(editing.description) 407 setSoul(editing.soul || '') 408 setSoulInitial(editing.soul || '') 409 setSoulSaveState('idle') 410 setSystemPrompt(editing.systemPrompt) 411 setProvider(editing.provider) 412 setModel(editing.model) 413 setCredentialId(editing.credentialId || null) 414 setApiEndpoint(editing.apiEndpoint || null) 415 setGatewayProfileId(editing.gatewayProfileId || null) 416 setPreferredGatewayTagsText(formatGatewayTagList(editing.preferredGatewayTags)) 417 setPreferredGatewayUseCase(editing.preferredGatewayUseCase || '') 418 setRoutingStrategy(editing.routingStrategy || 'single') 419 setRoutingTargets(editing.routingTargets || []) 420 setRole(editing.role === 'coordinator' ? 'coordinator' : 'worker') 421 setDelegationEnabled(editing.delegationEnabled === true) 422 setDelegationTargetMode(editing.delegationTargetMode === 'selected' ? 'selected' : 'all') 423 setDelegationTargetAgentIds(editing.delegationTargetAgentIds || []) 424 setTools(getEnabledToolIds(editing)) 425 setToolAccessMode(editing.toolAccessMode === 'scoped' ? 'scoped' : 'universal') 426 setExtensions(getEnabledExtensionIds(editing)) 427 setSkills(editing.skills || []) 428 setSkillIds(editing.skillIds || []) 429 setMcpServerIds(editing.mcpServerIds || []) 430 setMcpDisabledTools(editing.mcpDisabledTools || []) 431 setFallbackCredentialIds(editing.fallbackCredentialIds || []) 432 setCapabilities(Array.isArray(editing.capabilities) ? editing.capabilities : []) 433 setCapInput('') 434 setOllamaMode(resolveStoredOllamaMode({ 435 ollamaMode: editing.ollamaMode ?? null, 436 apiEndpoint: editing.apiEndpoint ?? null, 437 })) 438 setOpenclawEnabled(editing.provider === 'openclaw') 439 setProjectId(editing.projectId) 440 setAvatarSeed(editing.avatarSeed || Math.random().toString(36).slice(2, 10)) 441 setAvatarUrl(editing.avatarUrl || null) 442 setThinkingLevel(editing.thinkingLevel || '') 443 setMemoryScopeMode(editing.memoryScopeMode || 'auto') 444 setMemoryTierPreference(editing.memoryTierPreference || 'blended') 445 setProactiveMemory(editing.proactiveMemory !== false) 446 setAutoDraftSkillSuggestions(editing.autoDraftSkillSuggestions !== false) 447 setAutoRecovery(editing.autoRecovery || false) 448 setDisabled(editing.disabled === true) 449 setFilesystemScope(editing.filesystemScope === 'machine' ? 'machine' : 'workspace') 450 setVoiceId(editing.elevenLabsVoiceId || '') 451 setHeartbeatEnabled(editing.heartbeatEnabled || false) 452 setHeartbeatIntervalSec(parseDurationToSec(editing.heartbeatInterval, editing.heartbeatIntervalSec)) 453 setHeartbeatModel(editing.heartbeatModel || '') 454 setHeartbeatPrompt(editing.heartbeatPrompt || '') 455 setDreamEnabled(editing.dreamEnabled || false) 456 setDreamCooldownMinutes(editing.dreamConfig?.cooldownMinutes != null ? String(editing.dreamConfig.cooldownMinutes) : '360') 457 setDreamTier2Enabled(editing.dreamConfig?.tier2Enabled !== false) 458 setOrchestratorEnabled(editing.orchestratorEnabled || false) 459 setOrchestratorMission(editing.orchestratorMission || '') 460 setOrchestratorWakeInterval(typeof editing.orchestratorWakeInterval === 'string' ? editing.orchestratorWakeInterval : typeof editing.orchestratorWakeInterval === 'number' ? `${editing.orchestratorWakeInterval}s` : '5m') 461 setOrchestratorGovernance(editing.orchestratorGovernance || 'autonomous') 462 setOrchestratorMaxCyclesPerDay(editing.orchestratorMaxCyclesPerDay != null ? String(editing.orchestratorMaxCyclesPerDay) : '') 463 setSessionResetMode(editing.sessionResetMode || '') 464 setSessionIdleTimeoutSec(editing.sessionIdleTimeoutSec != null ? String(editing.sessionIdleTimeoutSec) : '') 465 setSessionMaxAgeSec(editing.sessionMaxAgeSec != null ? String(editing.sessionMaxAgeSec) : '') 466 setSessionDailyResetAt(editing.sessionDailyResetAt || '') 467 setSessionResetTimezone(editing.sessionResetTimezone || '') 468 setIdentityPersonaLabel(editing.identityState?.personaLabel || '') 469 setIdentitySelfSummary(editing.identityState?.selfSummary || '') 470 setIdentityRelationshipSummary(editing.identityState?.relationshipSummary || '') 471 setIdentityToneStyle(editing.identityState?.toneStyle || '') 472 setIdentityBoundariesText(formatIdentityList(editing.identityState?.boundaries)) 473 setIdentityContinuityNotesText(formatIdentityList(editing.identityState?.continuityNotes)) 474 setBudgetEnabled( 475 (typeof editing.hourlyBudget === 'number' && editing.hourlyBudget > 0) 476 || (typeof editing.dailyBudget === 'number' && editing.dailyBudget > 0) 477 || (typeof editing.monthlyBudget === 'number' && editing.monthlyBudget > 0), 478 ) 479 setHourlyBudget(typeof editing.hourlyBudget === 'number' && editing.hourlyBudget > 0 ? String(editing.hourlyBudget) : '') 480 setDailyBudget(typeof editing.dailyBudget === 'number' && editing.dailyBudget > 0 ? String(editing.dailyBudget) : '') 481 setMonthlyBudget(typeof editing.monthlyBudget === 'number' && editing.monthlyBudget > 0 ? String(editing.monthlyBudget) : '') 482 setBudgetAction(editing.budgetAction || 'warn') 483 } else if (useAppStore.getState().agentPrefill) { 484 // Duplicate mode — prefill from source agent, then clear 485 const src = useAppStore.getState().agentPrefill! 486 setAgentPrefill(null) 487 skipAutoModelRef.current = true 488 setName(`${src.name || 'Agent'} (Copy)`) 489 setDescription(src.description || '') 490 setSoul(src.soul || '') 491 setSoulInitial(src.soul || '') 492 setSoulSaveState('idle') 493 setSystemPrompt(src.systemPrompt || '') 494 setProvider(src.provider || 'claude-cli') 495 setModel(src.model || '') 496 setCredentialId(src.credentialId || null) 497 setApiEndpoint(src.apiEndpoint || null) 498 setGatewayProfileId(src.gatewayProfileId || null) 499 setPreferredGatewayTagsText(formatGatewayTagList(src.preferredGatewayTags)) 500 setPreferredGatewayUseCase(src.preferredGatewayUseCase || '') 501 setRoutingStrategy(src.routingStrategy || 'single') 502 setRoutingTargets(src.routingTargets || []) 503 setRole(src.role === 'coordinator' ? 'coordinator' : 'worker') 504 setDelegationEnabled(src.delegationEnabled === true) 505 setDelegationTargetMode(src.delegationTargetMode === 'selected' ? 'selected' : 'all') 506 setDelegationTargetAgentIds(src.delegationTargetAgentIds || []) 507 setTools(getEnabledToolIds(src)) 508 setToolAccessMode(src.toolAccessMode === 'scoped' ? 'scoped' : 'universal') 509 setExtensions(getEnabledExtensionIds(src)) 510 setSkills(src.skills || []) 511 setSkillIds(src.skillIds || []) 512 setMcpServerIds(src.mcpServerIds || []) 513 setMcpDisabledTools(src.mcpDisabledTools || []) 514 setFallbackCredentialIds(src.fallbackCredentialIds || []) 515 setCapabilities(Array.isArray(src.capabilities) ? src.capabilities : []) 516 setCapInput('') 517 setOllamaMode(resolveStoredOllamaMode({ 518 ollamaMode: src.ollamaMode ?? null, 519 apiEndpoint: src.apiEndpoint ?? null, 520 })) 521 setOpenclawEnabled(src.provider === 'openclaw') 522 setProjectId(src.projectId) 523 setAvatarSeed(Math.random().toString(36).slice(2, 10)) 524 setAvatarUrl(null) 525 setThinkingLevel(src.thinkingLevel || '') 526 setMemoryScopeMode(src.memoryScopeMode || 'auto') 527 setMemoryTierPreference(src.memoryTierPreference || 'blended') 528 setProactiveMemory(src.proactiveMemory !== false) 529 setAutoDraftSkillSuggestions(src.autoDraftSkillSuggestions !== false) 530 setAutoRecovery(src.autoRecovery || false) 531 setDisabled(false) 532 setFilesystemScope(src.filesystemScope === 'machine' ? 'machine' : 'workspace') 533 setVoiceId(src.elevenLabsVoiceId || '') 534 setHeartbeatEnabled(src.heartbeatEnabled || false) 535 setHeartbeatIntervalSec(parseDurationToSec(src.heartbeatInterval, src.heartbeatIntervalSec)) 536 setHeartbeatModel(src.heartbeatModel || '') 537 setHeartbeatPrompt(src.heartbeatPrompt || '') 538 setDreamEnabled(src.dreamEnabled || false) 539 setDreamCooldownMinutes(src.dreamConfig?.cooldownMinutes != null ? String(src.dreamConfig.cooldownMinutes) : '360') 540 setDreamTier2Enabled(src.dreamConfig?.tier2Enabled !== false) 541 setOrchestratorEnabled(src.orchestratorEnabled || false) 542 setOrchestratorMission(src.orchestratorMission || '') 543 setOrchestratorWakeInterval(typeof src.orchestratorWakeInterval === 'string' ? src.orchestratorWakeInterval : typeof src.orchestratorWakeInterval === 'number' ? `${src.orchestratorWakeInterval}s` : '5m') 544 setOrchestratorGovernance(src.orchestratorGovernance || 'autonomous') 545 setOrchestratorMaxCyclesPerDay(src.orchestratorMaxCyclesPerDay != null ? String(src.orchestratorMaxCyclesPerDay) : '') 546 setSessionResetMode(src.sessionResetMode || '') 547 setSessionIdleTimeoutSec(src.sessionIdleTimeoutSec != null ? String(src.sessionIdleTimeoutSec) : '') 548 setSessionMaxAgeSec(src.sessionMaxAgeSec != null ? String(src.sessionMaxAgeSec) : '') 549 setSessionDailyResetAt(src.sessionDailyResetAt || '') 550 setSessionResetTimezone(src.sessionResetTimezone || '') 551 setIdentityPersonaLabel(src.identityState?.personaLabel || '') 552 setIdentitySelfSummary(src.identityState?.selfSummary || '') 553 setIdentityRelationshipSummary(src.identityState?.relationshipSummary || '') 554 setIdentityToneStyle(src.identityState?.toneStyle || '') 555 setIdentityBoundariesText(formatIdentityList(src.identityState?.boundaries)) 556 setIdentityContinuityNotesText(formatIdentityList(src.identityState?.continuityNotes)) 557 setBudgetEnabled( 558 (typeof src.hourlyBudget === 'number' && src.hourlyBudget > 0) 559 || (typeof src.dailyBudget === 'number' && src.dailyBudget > 0) 560 || (typeof src.monthlyBudget === 'number' && src.monthlyBudget > 0), 561 ) 562 setHourlyBudget(typeof src.hourlyBudget === 'number' && src.hourlyBudget > 0 ? String(src.hourlyBudget) : '') 563 setDailyBudget(typeof src.dailyBudget === 'number' && src.dailyBudget > 0 ? String(src.dailyBudget) : '') 564 setMonthlyBudget(typeof src.monthlyBudget === 'number' && src.monthlyBudget > 0 ? String(src.monthlyBudget) : '') 565 setBudgetAction(src.budgetAction || 'warn') 566 } else { 567 setName('') 568 setDescription('') 569 const newSoul = randomSoul() 570 setSoul(newSoul) 571 setSoulInitial(newSoul) 572 setSoulSaveState('idle') 573 setSystemPrompt('') 574 setProvider('claude-cli') 575 setModel('') 576 setCredentialId(null) 577 setApiEndpoint(null) 578 setGatewayProfileId(null) 579 setPreferredGatewayTagsText('') 580 setPreferredGatewayUseCase('') 581 setRoutingStrategy('single') 582 setRoutingTargets([]) 583 setRole('worker') 584 setDelegationEnabled(false) 585 setDelegationTargetMode('all') 586 setDelegationTargetAgentIds([]) 587 setTools(getDefaultAgentToolIds()) 588 setToolAccessMode('scoped') 589 setExtensions([]) 590 setSkills([]) 591 setSkillIds([]) 592 setMcpDisabledTools([]) 593 setFallbackCredentialIds([]) 594 setCapabilities([]) 595 setCapInput('') 596 setOllamaMode('local') 597 setOpenclawEnabled(false) 598 setProjectId(undefined) 599 setAvatarSeed('') 600 setThinkingLevel('') 601 setMemoryScopeMode('auto') 602 setMemoryTierPreference('blended') 603 setProactiveMemory(true) 604 setAutoDraftSkillSuggestions(true) 605 setAutoRecovery(false) 606 setDisabled(false) 607 setVoiceId('') 608 setHeartbeatEnabled(true) 609 setHeartbeatIntervalSec('') 610 setHeartbeatModel('') 611 setHeartbeatPrompt('') 612 setOrchestratorEnabled(false) 613 setOrchestratorMission('') 614 setOrchestratorWakeInterval('5m') 615 setOrchestratorGovernance('autonomous') 616 setOrchestratorMaxCyclesPerDay('') 617 setSessionResetMode('') 618 setSessionIdleTimeoutSec('') 619 setSessionMaxAgeSec('') 620 setSessionDailyResetAt('') 621 setSessionResetTimezone('') 622 setIdentityPersonaLabel('') 623 setIdentitySelfSummary('') 624 setIdentityRelationshipSummary('') 625 setIdentityToneStyle('') 626 setIdentityBoundariesText('') 627 setIdentityContinuityNotesText('') 628 setBudgetEnabled(false) 629 setHourlyBudget('') 630 setDailyBudget('') 631 setMonthlyBudget('') 632 setBudgetAction('warn') 633 } 634 } 635 // eslint-disable-next-line react-hooks/exhaustive-deps 636 }, [open, editingId]) 637 638 useEffect(() => { 639 if (skipAutoModelRef.current) { 640 skipAutoModelRef.current = false 641 return 642 } 643 if (currentProvider?.models.length && !editing) { 644 setModel(currentProvider.models[0]) 645 } 646 // eslint-disable-next-line react-hooks/exhaustive-deps 647 }, [provider, agentSelectableProviders]) 648 649 // Reset test status when connection params change 650 useEffect(() => { 651 setTestStatus('idle') 652 setTestMessage('') 653 }, [provider, credentialId, apiEndpoint]) 654 655 // Fetch MCP tools when selected servers change 656 useEffect(() => { 657 if (!mcpServerIds.length) { 658 setMcpTools({}) 659 return 660 } 661 let cancelled = false 662 setMcpToolsLoading(true) 663 Promise.all( 664 mcpServerIds.map(async (id) => { 665 try { 666 const tools = await api<{ name: string; description: string }[]>('GET', `/mcp-servers/${id}/tools`) 667 return { id, tools: Array.isArray(tools) ? tools : [] } 668 } catch { 669 return { id, tools: [] } 670 } 671 }) 672 ).then((results) => { 673 if (cancelled) return 674 const map: Record<string, { name: string; description: string }[]> = {} 675 for (const r of results) map[r.id] = r.tools 676 setMcpTools(map) 677 setMcpToolsLoading(false) 678 }) 679 return () => { cancelled = true } 680 // eslint-disable-next-line react-hooks/exhaustive-deps 681 }, [mcpServerIds.join(',')]) 682 683 // Fetch OpenClaw device ID when toggle is enabled 684 useEffect(() => { 685 if (!openclawEnabled) return 686 let cancelled = false 687 api<{ deviceId: string }>('GET', '/setup/openclaw-device').then((res) => { 688 if (!cancelled && res.deviceId) setOpenclawDeviceId(res.deviceId) 689 }).catch(() => {}) 690 return () => { cancelled = true } 691 }, [openclawEnabled]) 692 693 const onClose = () => { 694 setOpen(false) 695 setEditingId(null) 696 } 697 698 const applyGatewayProfileSelection = (nextGatewayProfileId: string | null) => { 699 setGatewayProfileId(nextGatewayProfileId) 700 const gateway = openclawGatewayProfiles.find((item) => item.id === nextGatewayProfileId) 701 if (!gateway) return 702 setProvider('openclaw') 703 setOpenclawEnabled(true) 704 setApiEndpoint(gateway.endpoint) 705 if (gateway.credentialId) setCredentialId(gateway.credentialId) 706 if (!model) setModel('default') 707 } 708 709 const updateRoutingTarget = (targetId: string, patch: Partial<AgentRoutingTarget>) => { 710 setRoutingTargets((current) => current.map((target) => ( 711 target.id === targetId 712 ? { ...target, ...patch } 713 : target 714 ))) 715 } 716 717 const removeRoutingTarget = (targetId: string) => { 718 setRoutingTargets((current) => current.filter((target) => target.id !== targetId)) 719 } 720 721 const addRoutingTargetFromCurrent = () => { 722 const nextTarget: AgentRoutingTarget = { 723 id: Math.random().toString(16).slice(2, 10), 724 label: routingTargets.length === 0 ? 'Primary route' : `Route ${routingTargets.length + 1}`, 725 role: routingTargets.length === 0 ? 'primary' : 'backup', 726 provider, 727 model, 728 ollamaMode: provider === 'ollama' ? ollamaMode : null, 729 credentialId, 730 fallbackCredentialIds, 731 apiEndpoint, 732 gatewayProfileId, 733 preferredGatewayTags: parseGatewayTagList(preferredGatewayTagsText), 734 preferredGatewayUseCase: preferredGatewayUseCase || null, 735 priority: routingTargets.length + 1, 736 } 737 setRoutingTargets((current) => [...current, nextTarget]) 738 } 739 740 const handleSave = async () => { 741 // For any endpoint, just ensure bare host:port gets a protocol prepended 742 let normalizedEndpoint = apiEndpoint 743 if (normalizedEndpoint) { 744 const url = normalizedEndpoint.trim().replace(/\/+$/, '') 745 normalizedEndpoint = /^(https?|wss?):\/\//i.test(url) ? url : `http://${url}` 746 } 747 const parsedHourlyBudget = budgetEnabled && hourlyBudget ? Number(hourlyBudget) : null 748 const parsedDailyBudget = budgetEnabled && dailyBudget ? Number(dailyBudget) : null 749 const parsedMonthlyBudget = budgetEnabled && monthlyBudget ? Number(monthlyBudget) : null 750 const parsedSessionIdleTimeoutSec = sessionIdleTimeoutSec ? Number(sessionIdleTimeoutSec) : null 751 const parsedSessionMaxAgeSec = sessionMaxAgeSec ? Number(sessionMaxAgeSec) : null 752 const identityBoundaries = parseIdentityList(identityBoundariesText) 753 const identityContinuityNotes = parseIdentityList(identityContinuityNotesText) 754 const identityState = (() => { 755 const value = { 756 personaLabel: identityPersonaLabel.trim() || undefined, 757 selfSummary: identitySelfSummary.trim() || undefined, 758 relationshipSummary: identityRelationshipSummary.trim() || undefined, 759 toneStyle: identityToneStyle.trim() || undefined, 760 boundaries: identityBoundaries.length ? identityBoundaries : undefined, 761 continuityNotes: identityContinuityNotes.length ? identityContinuityNotes : undefined, 762 } 763 return Object.values(value).some((entry) => Array.isArray(entry) ? entry.length > 0 : Boolean(entry)) ? value : null 764 })() 765 const data = { 766 name: name.trim() || 'Unnamed Agent', 767 description, 768 soul, 769 systemPrompt, 770 provider, 771 model, 772 ollamaMode: provider === 'ollama' ? ollamaMode : null, 773 credentialId, 774 apiEndpoint: normalizedEndpoint, 775 gatewayProfileId, 776 preferredGatewayTags: parseGatewayTagList(preferredGatewayTagsText), 777 preferredGatewayUseCase: preferredGatewayUseCase || null, 778 routingStrategy, 779 routingTargets: routingTargets.map((target, index) => ({ 780 ...target, 781 ollamaMode: target.provider === 'ollama' 782 ? resolveStoredOllamaMode({ 783 ollamaMode: target.ollamaMode ?? null, 784 apiEndpoint: target.apiEndpoint ?? null, 785 }) 786 : null, 787 preferredGatewayTags: parseGatewayTagList(formatGatewayTagList(target.preferredGatewayTags)), 788 preferredGatewayUseCase: target.preferredGatewayUseCase || null, 789 priority: typeof target.priority === 'number' ? target.priority : index + 1, 790 })), 791 role, 792 delegationEnabled: role === 'coordinator' ? true : delegationEnabled, 793 delegationTargetMode: delegationEnabled || role === 'coordinator' ? delegationTargetMode : 'all', 794 delegationTargetAgentIds: (delegationEnabled || role === 'coordinator') && delegationTargetMode === 'selected' ? delegationTargetAgentIds : [], 795 tools, 796 toolAccessMode, 797 extensions, 798 skills, 799 skillIds, 800 mcpServerIds, 801 mcpDisabledTools: mcpDisabledTools.length ? mcpDisabledTools : undefined, 802 fallbackCredentialIds, 803 capabilities, 804 projectId: projectId || undefined, 805 avatarSeed: avatarSeed.trim() || undefined, 806 avatarUrl: avatarUrl || null, 807 thinkingLevel: thinkingLevel || undefined, 808 memoryScopeMode, 809 memoryTierPreference, 810 proactiveMemory, 811 autoDraftSkillSuggestions, 812 autoRecovery, 813 disabled, 814 filesystemScope: filesystemScope === 'machine' ? 'machine' as const : undefined, 815 elevenLabsVoiceId: voiceId.trim() || null, 816 heartbeatEnabled, 817 heartbeatInterval: heartbeatIntervalSec ? formatHbDuration(Number(heartbeatIntervalSec)) : null, 818 heartbeatIntervalSec: heartbeatIntervalSec ? Number(heartbeatIntervalSec) : null, 819 heartbeatModel: heartbeatModel.trim() || null, 820 heartbeatPrompt: heartbeatPrompt.trim() || null, 821 dreamEnabled, 822 dreamConfig: dreamEnabled 823 ? { cooldownMinutes: Number(dreamCooldownMinutes) || 360, tier2Enabled: dreamTier2Enabled } 824 : null, 825 orchestratorEnabled, 826 orchestratorMission: orchestratorMission.trim() || undefined, 827 orchestratorWakeInterval: orchestratorWakeInterval.trim() || null, 828 orchestratorGovernance, 829 orchestratorMaxCyclesPerDay: orchestratorMaxCyclesPerDay ? Number(orchestratorMaxCyclesPerDay) : null, 830 identityState, 831 sessionResetMode: sessionResetMode || null, 832 sessionIdleTimeoutSec: Number.isFinite(parsedSessionIdleTimeoutSec) && parsedSessionIdleTimeoutSec! >= 0 ? parsedSessionIdleTimeoutSec : null, 833 sessionMaxAgeSec: Number.isFinite(parsedSessionMaxAgeSec) && parsedSessionMaxAgeSec! >= 0 ? parsedSessionMaxAgeSec : null, 834 sessionDailyResetAt: sessionDailyResetAt.trim() || null, 835 sessionResetTimezone: sessionResetTimezone.trim() || null, 836 hourlyBudget: parsedHourlyBudget && parsedHourlyBudget > 0 ? parsedHourlyBudget : null, 837 dailyBudget: parsedDailyBudget && parsedDailyBudget > 0 ? parsedDailyBudget : null, 838 monthlyBudget: parsedMonthlyBudget && parsedMonthlyBudget > 0 ? parsedMonthlyBudget : null, 839 budgetAction: budgetEnabled ? budgetAction : undefined, 840 } 841 if (WORKER_ONLY_PROVIDER_IDS.has(provider)) { 842 data.role = 'worker' 843 data.delegationEnabled = false 844 data.heartbeatEnabled = false 845 data.heartbeatInterval = null 846 data.heartbeatIntervalSec = null 847 data.heartbeatModel = null 848 data.heartbeatPrompt = null 849 data.dreamEnabled = false 850 data.dreamConfig = null 851 } 852 const savedAgent = editing 853 ? await updateAgent(editing.id, data) 854 : await createAgent(data) 855 updateAgentInStore(savedAgent) 856 if (editing) { 857 toast.success('Agent saved') 858 } else { 859 toast.success('Agent created') 860 } 861 await loadAgents() 862 if ( 863 editing 864 && activeSessionId 865 && currentSession?.agentId === editing.id 866 && ( 867 currentSession.shortcutForAgentId === editing.id 868 || activeSessionId === editing.threadSessionId 869 ) 870 ) { 871 await refreshSession(activeSessionId) 872 } 873 setSoulInitial(soul) 874 setSoulSaveState('saved') 875 setTimeout(() => setSoulSaveState('idle'), 1500) 876 onClose() 877 } 878 879 const handleDelete = async () => { 880 if (editing) { 881 await deleteAgent(editing.id) 882 toast.success('Agent moved to trash') 883 await loadAgents() 884 onClose() 885 } 886 } 887 888 const handleExport = () => { 889 if (!editing) return 890 const recommendedProviders = agentSelectableProviders.some((providerOption) => ( 891 providerOption.id === editing.provider && providerOption.type === 'builtin' 892 )) 893 ? [editing.provider as ProviderType] 894 : undefined 895 const pack: AgentPackManifest = { 896 schemaVersion: 1, 897 kind: 'swarmclaw-agent-pack', 898 name: `${editing.name} Pack`, 899 description: editing.description || undefined, 900 exportedAt: Date.now(), 901 recommendedProviders, 902 agents: [{ 903 id: editing.name.replace(/\s+/g, '-').toLowerCase(), 904 name: editing.name, 905 description: editing.description || undefined, 906 provider: editing.provider, 907 model: editing.model, 908 ollamaMode: editing.provider === 'ollama' ? (editing.ollamaMode || 'local') : null, 909 credentialId: editing.credentialId || null, 910 fallbackCredentialIds: editing.fallbackCredentialIds || [], 911 apiEndpoint: editing.apiEndpoint || null, 912 gatewayProfileId: editing.gatewayProfileId || null, 913 routingStrategy: editing.routingStrategy || null, 914 routingTargets: editing.routingTargets || [], 915 tools: getEnabledToolIds(editing), 916 extensions: getEnabledExtensionIds(editing), 917 capabilities: editing.capabilities, 918 elevenLabsVoiceId: editing.elevenLabsVoiceId || null, 919 soul: editing.soul, 920 systemPrompt: editing.systemPrompt, 921 }], 922 } 923 const blob = new Blob([JSON.stringify(pack, null, 2)], { type: 'application/json' }) 924 const url = URL.createObjectURL(blob) 925 const a = document.createElement('a') 926 a.href = url 927 a.download = `${editing.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.agent-pack.json` 928 a.click() 929 URL.revokeObjectURL(url) 930 toast.success('Agent pack exported') 931 } 932 933 const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => { 934 const file = e.target.files?.[0] 935 if (!file) return 936 const reader = new FileReader() 937 reader.onload = async (ev) => { 938 try { 939 const data = JSON.parse(ev.target?.result as string) 940 const importedAgent = data?.kind === 'swarmclaw-agent-pack' 941 ? data?.agents?.[0] 942 : data 943 if (!importedAgent || typeof importedAgent !== 'object') throw new Error('Invalid agent pack') 944 // Strip IDs and timestamps 945 const { id: _id, createdAt: _ca, updatedAt: _ua, threadSessionId: _ts, ...agentData } = importedAgent 946 void [_id, _ca, _ua, _ts] 947 await createAgent({ ...agentData, name: agentData.name || 'Imported Agent' }) 948 await loadAgents() 949 toast.success(data?.kind === 'swarmclaw-agent-pack' ? 'Agent pack imported' : 'Agent imported') 950 onClose() 951 } catch { 952 toast.error('Invalid agent JSON file') 953 } 954 } 955 reader.readAsText(file) 956 e.target.value = '' 957 } 958 959 const handleTestConnection = async (): Promise<boolean> => { 960 setTestStatus('testing') 961 setTestMessage('') 962 setTestErrorCode(null) 963 try { 964 const result = await api<{ ok: boolean; message: string; errorCode?: string; deviceId?: string }>('POST', '/setup/check-provider', { 965 provider, 966 credentialId, 967 endpoint: apiEndpoint, 968 model, 969 ollamaMode: provider === 'ollama' ? ollamaMode : null, 970 }, { 971 timeoutMs: CONNECTION_TEST_TIMEOUT_MS, 972 }) 973 if (result.deviceId) setTestDeviceId(result.deviceId) 974 if (result.ok) { 975 let syncedModels: string[] = [] 976 try { 977 const synced = await syncLiveProviderModels(provider, credentialId, apiEndpoint, ollamaMode, true) 978 syncedModels = synced?.models || [] 979 } catch { 980 // Best-effort: a passing connection test should still pass if model sync fails. 981 } 982 setTestStatus('pass') 983 setTestMessage( 984 syncedModels.length > 0 985 ? `${result.message} Synced ${syncedModels.length} live model${syncedModels.length === 1 ? '' : 's'} into the model picker.` 986 : result.message, 987 ) 988 return true 989 } else { 990 setTestStatus('fail') 991 setTestMessage(result.message) 992 setTestErrorCode(result.errorCode || null) 993 toast.error(result.message || 'Connection test failed') 994 return false 995 } 996 } catch (err: unknown) { 997 const msg = err instanceof Error ? err.message : 'Connection test failed' 998 setTestStatus('fail') 999 setTestMessage(msg) 1000 toast.error(msg) 1001 return false 1002 } 1003 } 1004 1005 // Whether this provider needs a connection test before saving. 1006 // Only CLI providers (no remote connection) skip the test. 1007 const needsTest = !providerNeedsKey && !NON_LANGGRAPH_PROVIDER_IDS.has(provider) 1008 1009 const [saving, setSaving] = useState(false) 1010 1011 const handleTestAndSave = async () => { 1012 if (needsTest) { 1013 const passed = await handleTestConnection() 1014 if (!passed) return 1015 if (!openclawEnabled) { 1016 // Brief pause so the user can see the success state on the button 1017 await sleep(1500) 1018 } 1019 } 1020 setSaving(true) 1021 await handleSave() 1022 setSaving(false) 1023 } 1024 1025 const canDelegateToAgents = delegationEnabled || role === 'coordinator' 1026 const agentOptions = Object.values(agents).filter((p) => p.id !== editingId) 1027 const defaultAgentToolIds = useMemo(() => getDefaultAgentToolIds(), []) 1028 const toolsDifferFromDefault = tools.length !== defaultAgentToolIds.length 1029 || defaultAgentToolIds.some((toolId) => !tools.includes(toolId)) 1030 const agentAdvancedBadges = useMemo(() => { 1031 const badges: string[] = [] 1032 if (voiceId.trim()) badges.push('Voice') 1033 if (routingStrategy !== 'single' || routingTargets.length > 0 || fallbackCredentialIds.length > 0) badges.push('Routing') 1034 if (memoryScopeMode !== 'auto' || memoryTierPreference !== 'blended' || !proactiveMemory) badges.push('Memory') 1035 if (sessionResetMode || sessionIdleTimeoutSec || sessionMaxAgeSec || sessionDailyResetAt || sessionResetTimezone) badges.push('Session reset') 1036 if (identityPersonaLabel.trim() || identitySelfSummary.trim() || identityRelationshipSummary.trim() || identityToneStyle.trim()) badges.push('Continuity') 1037 if (skills.length > 0 || skillIds.length > 0 || mcpServerIds.length > 0 || mcpDisabledTools.length > 0) badges.push('Skills & MCP') 1038 if (toolsDifferFromDefault || filesystemScope === 'machine') badges.push('Tools') 1039 if (budgetEnabled) badges.push('Budget') 1040 if (dreamEnabled) badges.push('Dreaming') 1041 if (disabled) badges.push('Disabled') 1042 if (autoRecovery) badges.push('Recovery') 1043 if (projectId) badges.push('Project') 1044 if (thinkingLevel) badges.push('Thinking') 1045 if (!autoDraftSkillSuggestions) badges.push('Skill drafting') 1046 return Array.from(new Set(badges)) 1047 }, [ 1048 autoDraftSkillSuggestions, 1049 autoRecovery, 1050 budgetEnabled, 1051 disabled, 1052 dreamEnabled, 1053 fallbackCredentialIds.length, 1054 filesystemScope, 1055 identityPersonaLabel, 1056 identityRelationshipSummary, 1057 identitySelfSummary, 1058 identityToneStyle, 1059 mcpDisabledTools.length, 1060 mcpServerIds.length, 1061 memoryScopeMode, 1062 memoryTierPreference, 1063 proactiveMemory, 1064 projectId, 1065 routingStrategy, 1066 routingTargets.length, 1067 sessionDailyResetAt, 1068 sessionIdleTimeoutSec, 1069 sessionMaxAgeSec, 1070 sessionResetMode, 1071 sessionResetTimezone, 1072 skillIds.length, 1073 skills.length, 1074 thinkingLevel, 1075 toolsDifferFromDefault, 1076 voiceId, 1077 ]) 1078 const advancedSummary = agentAdvancedBadges.length > 0 1079 ? `${agentAdvancedBadges.length} configured` 1080 : 'Defaults only' 1081 1082 const toggleAgent = (id: string) => { 1083 setDelegationTargetMode('selected') 1084 setDelegationTargetAgentIds((prev) => { 1085 const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] 1086 if (next.length === 0) { 1087 setDelegationTargetMode('all') 1088 } 1089 return next 1090 }) 1091 } 1092 1093 const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow" 1094 1095 return ( 1096 <> 1097 <BottomSheet open={open} onClose={onClose} wide> 1098 <div className="mb-8 pr-14 sm:pr-20"> 1099 <div className="min-w-0"> 1100 <div className="mb-2 flex flex-wrap items-center gap-2"> 1101 <h2 className="font-display text-[28px] font-700 tracking-[-0.03em]"> 1102 {editing ? 'Edit Agent' : 'New Agent'} 1103 </h2> 1104 <span className={`rounded-[999px] px-2.5 py-1 text-[10px] font-700 uppercase tracking-[0.1em] ${ 1105 disabled 1106 ? 'border border-amber-400/20 bg-amber-400/[0.08] text-amber-300' 1107 : 'border border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-300' 1108 }`}> 1109 {disabled ? 'Disabled' : 'Enabled'} 1110 </span> 1111 </div> 1112 <p className="text-[14px] text-text-3">Set up an agent with sensible defaults, then expand advanced settings if you need deeper control.</p> 1113 </div> 1114 </div> 1115 1116 <SectionCard 1117 title="Basics" 1118 description="Start with the core identity and description users will see first." 1119 > 1120 <div className="mb-8"> 1121 <SectionLabel>Name</SectionLabel> 1122 <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. SEO Researcher" className={inputClass} style={{ fontFamily: 'inherit' }} /> 1123 </div> 1124 1125 <div className="mb-8"> 1126 <SectionLabel>Avatar</SectionLabel> 1127 <div className="flex flex-col gap-3"> 1128 <div className="flex items-center gap-4"> 1129 <div className="relative group shrink-0"> 1130 <AgentAvatar seed={avatarUrl ? null : (avatarSeed || null)} avatarUrl={avatarUrl} name={name || 'A'} size={64} /> 1131 <label className="absolute inset-0 rounded-full flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"> 1132 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 1133 <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" /> 1134 <circle cx="12" cy="13" r="4" /> 1135 </svg> 1136 <input 1137 type="file" 1138 accept="image/*" 1139 className="hidden" 1140 onChange={async (e) => { 1141 const file = e.target.files?.[0] 1142 if (!file) return 1143 setUploading(true) 1144 try { 1145 const res = await fetch('/api/upload', { 1146 method: 'POST', 1147 headers: { 'x-filename': file.name }, 1148 body: await file.arrayBuffer(), 1149 }) 1150 const data = await res.json() 1151 if (data.url) { 1152 setAvatarUrl(data.url) 1153 setAvatarSeed('') 1154 toast.success('Avatar image uploaded') 1155 } 1156 } catch { 1157 toast.error('Failed to upload image') 1158 } finally { 1159 setUploading(false) 1160 e.target.value = '' 1161 } 1162 }} 1163 /> 1164 </label> 1165 </div> 1166 <div className="flex flex-col gap-1.5 flex-1 min-w-0"> 1167 {avatarUrl && ( 1168 <button 1169 type="button" 1170 onClick={() => { 1171 setAvatarUrl(null) 1172 if (!avatarSeed) setAvatarSeed(Math.random().toString(36).slice(2, 10)) 1173 }} 1174 className="text-[11px] text-text-3 hover:text-red-400 transition-colors self-start cursor-pointer" 1175 > 1176 Remove custom image 1177 </button> 1178 )} 1179 {uploading && <span className="text-[11px] text-text-3">Uploading...</span>} 1180 </div> 1181 </div> 1182 <div className="flex items-center gap-3"> 1183 <input 1184 type="text" 1185 value={avatarSeed} 1186 onChange={(e) => { setAvatarSeed(e.target.value); setAvatarUrl(null) }} 1187 placeholder="Avatar seed (any text)" 1188 className={inputClass} 1189 style={{ fontFamily: 'inherit', flex: 1 }} 1190 /> 1191 <button 1192 type="button" 1193 onClick={() => { setAvatarSeed(Math.random().toString(36).slice(2, 10)); setAvatarUrl(null) }} 1194 className="inline-flex items-center gap-1.5 px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-text-3 text-[12px] font-600 cursor-pointer transition-all hover:bg-white/[0.04] hover:text-text-2 active:scale-95 shrink-0" 1195 style={{ fontFamily: 'inherit' }} 1196 title="Shuffle avatar" 1197 > 1198 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 1199 <rect x="4" y="4" width="16" height="16" rx="2" /> 1200 <circle cx="9" cy="9" r="1" fill="currentColor" /> 1201 <circle cx="15" cy="15" r="1" fill="currentColor" /> 1202 </svg> 1203 Shuffle 1204 </button> 1205 </div> 1206 </div> 1207 </div> 1208 1209 <div className="mb-8"> 1210 <SectionLabel>Description</SectionLabel> 1211 <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What does this agent do?" className={inputClass} style={{ fontFamily: 'inherit' }} /> 1212 </div> 1213 </SectionCard> 1214 1215 <SectionCard 1216 title="Model & Connection" 1217 description="Choose how this agent connects to a model, then verify the setup before saving." 1218 > 1219 <div className="mb-8"> 1220 <div className="flex items-center justify-between gap-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-3"> 1221 <div> 1222 <p className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3">Runtime</p> 1223 <p className="mt-1 text-[14px] font-600 text-text">{openclawEnabled ? 'OpenClaw gateway' : 'Direct provider connection'}</p> 1224 </div> 1225 <div className="flex items-center gap-3"> 1226 <label className="text-[11px] font-600 uppercase tracking-[0.08em] text-text-3">OpenClaw</label> 1227 <button 1228 type="button" 1229 onClick={() => { 1230 if (!openclawEnabled) { 1231 setOpenclawEnabled(true) 1232 setProvider('openclaw') 1233 setModel('default') 1234 if (!apiEndpoint) setApiEndpoint('http://localhost:18789') 1235 } else { 1236 setOpenclawEnabled(false) 1237 const first = agentSelectableProviders[0]?.id || 'claude-cli' 1238 setProvider(first) 1239 setModel('') 1240 setApiEndpoint(null) 1241 setCredentialId(null) 1242 setGatewayProfileId(null) 1243 setTestStatus('idle') 1244 setTestMessage('') 1245 setTestErrorCode(null) 1246 } 1247 }} 1248 className={`relative h-6 w-11 rounded-full border-none transition-colors duration-200 ${openclawEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`} 1249 > 1250 <span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${openclawEnabled ? 'translate-x-5' : 'translate-x-0'}`} /> 1251 </button> 1252 </div> 1253 </div> 1254 </div> 1255 {/* OpenClaw Gateway Fields */} 1256 {openclawEnabled && ( 1257 <div className="mb-8 space-y-5"> 1258 {openclawGatewayProfiles.length > 0 && ( 1259 <div> 1260 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Gateway Profile</label> 1261 <select 1262 value={gatewayProfileId || ''} 1263 onChange={(e) => applyGatewayProfileSelection(e.target.value || null)} 1264 className={inputClass} 1265 > 1266 <option value="">Custom endpoint</option> 1267 {openclawGatewayProfiles.map((gateway) => ( 1268 <option key={gateway.id} value={gateway.id}> 1269 {gateway.name}{gateway.isDefault ? ' (default)' : ''} 1270 </option> 1271 ))} 1272 </select> 1273 </div> 1274 )} 1275 {/* Connection fields */} 1276 <div className="space-y-4"> 1277 <div> 1278 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Gateway URL</label> 1279 <input 1280 type="text" 1281 value={apiEndpoint || ''} 1282 onChange={(e) => setApiEndpoint(e.target.value || null)} 1283 placeholder="http://localhost:18789" 1284 className={inputClass} 1285 style={{ fontFamily: 'inherit' }} 1286 /> 1287 </div> 1288 <div> 1289 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Gateway Token</label> 1290 {openclawCredentials.length > 0 && !addingKey ? ( 1291 <div className="flex gap-2"> 1292 <select value={credentialId || ''} onChange={(e) => { 1293 if (e.target.value === '__add__') { 1294 setAddingKey(true) 1295 setNewKeyName('') 1296 setNewKeyValue('') 1297 } else { 1298 setCredentialId(e.target.value || null) 1299 } 1300 }} className={`${inputClass} appearance-none cursor-pointer flex-1`} style={{ fontFamily: 'inherit' }}> 1301 <option value="">No token (auth disabled)</option> 1302 {openclawCredentials.map((c) => ( 1303 <option key={c.id} value={c.id}>{c.name}</option> 1304 ))} 1305 <option value="__add__">+ Add new token...</option> 1306 </select> 1307 <button 1308 type="button" 1309 onClick={() => { setAddingKey(true); setNewKeyName(''); setNewKeyValue('') }} 1310 className="shrink-0 px-3 py-2.5 rounded-[10px] bg-accent-soft/50 text-accent-bright text-[12px] font-600 hover:bg-accent-soft transition-colors cursor-pointer border border-accent-bright/20" 1311 > 1312 + New 1313 </button> 1314 </div> 1315 ) : ( 1316 <div className="space-y-3 p-4 rounded-[12px] border border-accent-bright/15 bg-accent-soft/10"> 1317 <input 1318 type="text" 1319 value={newKeyName} 1320 onChange={(e) => setNewKeyName(e.target.value)} 1321 placeholder="Label (e.g. Local gateway)" 1322 className={inputClass} 1323 style={{ fontFamily: 'inherit' }} 1324 /> 1325 <input 1326 type="password" 1327 value={newKeyValue} 1328 onChange={(e) => setNewKeyValue(e.target.value)} 1329 placeholder="Paste gateway token..." 1330 className={inputClass} 1331 style={{ fontFamily: 'inherit' }} 1332 /> 1333 <div className="flex gap-2 justify-end"> 1334 {openclawCredentials.length > 0 && ( 1335 <button type="button" onClick={() => setAddingKey(false)} className="px-3 py-1.5 text-[12px] text-text-3 hover:text-text-2 transition-colors cursor-pointer bg-transparent border-none" style={{ fontFamily: 'inherit' }}>Cancel</button> 1336 )} 1337 <button 1338 type="button" 1339 disabled={savingKey || !newKeyValue.trim()} 1340 onClick={async () => { 1341 setSavingKey(true) 1342 try { 1343 const cred = await api<{ id: string }>('POST', '/credentials', { provider: 'openclaw', name: newKeyName.trim() || 'OpenClaw token', apiKey: newKeyValue.trim() }) 1344 await loadCredentials() 1345 setCredentialId(cred.id) 1346 setAddingKey(false) 1347 setNewKeyName('') 1348 setNewKeyValue('') 1349 } catch (err: unknown) { toast.error(`Failed to save: ${errorMessage(err)}`) } 1350 finally { setSavingKey(false) } 1351 }} 1352 className="px-4 py-1.5 rounded-[8px] bg-accent-bright text-white text-[12px] font-600 cursor-pointer border-none hover:brightness-110 transition-all disabled:opacity-40" 1353 style={{ fontFamily: 'inherit' }} 1354 > 1355 {savingKey ? 'Saving...' : 'Save Token'} 1356 </button> 1357 </div> 1358 </div> 1359 )} 1360 </div> 1361 </div> 1362 1363 {/* Insecure connection warning */} 1364 {(() => { 1365 const url = (apiEndpoint || '').trim().toLowerCase() 1366 const isRemote = url && !/localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/i.test(url) 1367 const isSecure = /^(https|wss):\/\//i.test(url) 1368 if (isRemote && !isSecure) return ( 1369 <div className="px-3 py-2.5 rounded-[10px] bg-[#fbbf24]/[0.06] border border-[#fbbf24]/20"> 1370 <p className="text-[13px] text-[#fbbf24] leading-[1.5]"> 1371 Unencrypted connection. Use HTTPS or an SSH tunnel for production. 1372 </p> 1373 </div> 1374 ) 1375 return null 1376 })()} 1377 1378 {/* Status feedback — single unified block */} 1379 {testStatus === 'pass' && ( 1380 <div className="p-4 rounded-[12px] bg-emerald-500/[0.06] border border-emerald-500/15 space-y-2"> 1381 <div className="flex items-center gap-2"> 1382 <StatusDot status="online" /> 1383 <p className="text-[14px] text-emerald-400 font-600">Connected</p> 1384 </div> 1385 <p className="text-[13px] text-text-2/80 leading-[1.6]">Gateway is reachable and this device is paired. Tools and models are managed by the OpenClaw instance.</p> 1386 </div> 1387 )} 1388 {testStatus === 'fail' && ( 1389 <div className="p-4 rounded-[12px] border space-y-3" 1390 style={{ 1391 background: testErrorCode === 'PAIRING_REQUIRED' ? 'rgba(34,197,94,0.04)' : 'rgba(var(--accent-bright-rgb,120,100,255),0.06)', 1392 borderColor: testErrorCode === 'PAIRING_REQUIRED' ? 'rgba(34,197,94,0.2)' : 'rgba(var(--accent-bright-rgb,120,100,255),0.15)', 1393 }} 1394 > 1395 {testErrorCode === 'PAIRING_REQUIRED' ? (<> 1396 <div className="flex items-center gap-2"> 1397 <StatusDot status="online" pulse /> 1398 <p className="text-[14px] text-[#22c55e] font-600">Awaiting Approval</p> 1399 </div> 1400 <p className="text-[13px] text-text-2/80 leading-[1.6]"> 1401 This device is pending approval on your gateway. Go to <span className="text-text-2 font-500">Nodes</span>, approve the device{(testDeviceId || openclawDeviceId) ? <> (<code className="text-[12px] font-mono text-text-2/70">{(testDeviceId || openclawDeviceId)!.slice(0, 12)}...</code>)</> : null}, then click <span className="text-text-2 font-500">Retry Connection</span>. 1402 </p> 1403 <a 1404 href={(() => { const ep = (apiEndpoint || 'http://localhost:18789').replace(/\/+$/, ''); return /^https?:\/\//i.test(ep) ? ep : `http://${ep}` })()} 1405 target="_blank" 1406 rel="noopener noreferrer" 1407 className="inline-flex items-center gap-1.5 mt-2 px-4 py-2 rounded-[10px] bg-white/[0.06] border border-white/[0.1] text-[13px] text-text-2 font-500 hover:bg-white/[0.1] transition-colors" 1408 > 1409 Approve in Dashboard → 1410 </a> 1411 </>) : testErrorCode === 'DEVICE_AUTH_INVALID' ? (<> 1412 <p className="text-[14px] text-accent-bright font-600">Device Not Paired</p> 1413 <p className="text-[13px] text-text-2/80 leading-[1.6]"> 1414 The gateway doesn't recognize this device. Go to <span className="text-text-2 font-500">Nodes</span>, and add or approve this device{(testDeviceId || openclawDeviceId) ? <> (<code className="text-[12px] font-mono text-text-2/70">{(testDeviceId || openclawDeviceId)!.slice(0, 12)}...</code>)</> : null}. 1415 </p> 1416 <a 1417 href={(() => { const ep = (apiEndpoint || 'http://localhost:18789').replace(/\/+$/, ''); return /^https?:\/\//i.test(ep) ? ep : `http://${ep}` })()} 1418 target="_blank" 1419 rel="noopener noreferrer" 1420 className="inline-flex items-center gap-1.5 mt-2 px-4 py-2 rounded-[10px] bg-white/[0.06] border border-white/[0.1] text-[13px] text-text-2 font-500 hover:bg-white/[0.1] transition-colors" 1421 > 1422 Approve in Dashboard → 1423 </a> 1424 </>) : testErrorCode === 'AUTH_TOKEN_MISSING' ? (<> 1425 <p className="text-[14px] text-accent-bright font-600">Token Required</p> 1426 <p className="text-[13px] text-text-2/80 leading-[1.6]"> 1427 This gateway requires an auth token. Add one above and try again. 1428 </p> 1429 </>) : testErrorCode === 'AUTH_TOKEN_INVALID' ? (<> 1430 <p className="text-[14px] text-accent-bright font-600">Invalid Token</p> 1431 <p className="text-[13px] text-text-2/80 leading-[1.6]"> 1432 The gateway rejected this token. Check that it matches the one configured on your OpenClaw instance. 1433 </p> 1434 </>) : (<> 1435 <p className="text-[14px] text-accent-bright font-600">Connection Failed</p> 1436 <p className="text-[13px] text-text-2/80 leading-[1.6]"> 1437 {testMessage || 'Could not reach the gateway. Check the URL, token, and that the gateway is running.'} 1438 </p> 1439 </>)} 1440 {/* Device ID footer — always shown on failure for debugging */} 1441 {(testDeviceId || openclawDeviceId) && testErrorCode !== 'AUTH_TOKEN_MISSING' && testErrorCode !== 'AUTH_TOKEN_INVALID' && ( 1442 <div className="pt-2 border-t border-white/[0.04]"> 1443 <p className="text-[12px] text-text-3/70 flex items-center gap-1.5"> 1444 Device <code className="font-mono text-text-2/70 select-all">{(testDeviceId || openclawDeviceId)}</code> 1445 <button 1446 type="button" 1447 onClick={() => { 1448 void copyTextToClipboard((testDeviceId || openclawDeviceId)!).then((copiedId) => { 1449 if (!copiedId) return 1450 setConfigCopied(true) 1451 setTimeout(() => setConfigCopied(false), 2000) 1452 }) 1453 }} 1454 className="text-[12px] text-text-3/60 hover:text-text-3/80 transition-colors cursor-pointer bg-transparent border-none" 1455 > 1456 {configCopied ? 'copied' : 'copy'} 1457 </button> 1458 </p> 1459 </div> 1460 )} 1461 </div> 1462 )} 1463 </div> 1464 )} 1465 1466 {!openclawEnabled && <div className="mb-8"> 1467 <SectionLabel>Provider</SectionLabel> 1468 <div className="grid grid-cols-3 gap-3"> 1469 {agentSelectableProviders.map((p) => { 1470 const nextCredentials = resolveAgentSelectableProviderCredentials(p.id, credentials, providerConfigs) 1471 const isConnected = !p.requiresApiKey || nextCredentials.length > 0 1472 return ( 1473 <button 1474 key={p.id} 1475 onClick={() => { 1476 setProvider(p.id) 1477 if (!nextCredentials.some((item) => item.id === credentialId)) { 1478 setCredentialId(nextCredentials[0]?.id || null) 1479 } 1480 setGatewayProfileId(null) 1481 }} 1482 className={`relative py-3.5 px-4 rounded-[14px] text-center cursor-pointer transition-all duration-200 1483 active:scale-[0.97] text-[14px] font-600 border 1484 ${provider === p.id 1485 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 1486 : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`} 1487 style={{ fontFamily: 'inherit' }} 1488 > 1489 {isConnected && ( 1490 <span className="absolute top-2 right-2 w-2 h-2 rounded-full bg-emerald-400" /> 1491 )} 1492 {p.name} 1493 </button> 1494 ) 1495 })} 1496 </div> 1497 </div>} 1498 1499 {!openclawEnabled && currentProvider && currentProvider.models.length > 0 && ( 1500 <div className="mb-8"> 1501 <SectionLabel>Model</SectionLabel> 1502 <ModelCombobox 1503 providerId={currentProvider.id} 1504 value={model} 1505 onChange={setModel} 1506 models={currentProvider.models} 1507 defaultModels={currentProvider.defaultModels} 1508 credentialId={credentialId} 1509 apiEndpoint={apiEndpoint} 1510 ollamaMode={provider === 'ollama' ? ollamaMode : null} 1511 supportsDiscovery={currentProvider.supportsModelDiscovery} 1512 className={`${inputClass} cursor-pointer`} 1513 /> 1514 </div> 1515 )} 1516 1517 {/* OpenClaw manages its own models — no selector needed */} 1518 1519 {/* Ollama Mode Toggle */} 1520 {!openclawEnabled && provider === 'ollama' && ( 1521 <div className="mb-8"> 1522 <SectionLabel>Mode</SectionLabel> 1523 <div className="flex p-1 rounded-[14px] bg-surface border border-white/[0.06]"> 1524 {(['local', 'cloud'] as const).map((mode) => ( 1525 <button 1526 key={mode} 1527 onClick={() => { 1528 setOllamaMode(mode) 1529 if (mode === 'local') { 1530 setApiEndpoint('http://localhost:11434') 1531 setCredentialId(null) 1532 } else { 1533 setApiEndpoint(null) 1534 if (providerCredentials.length > 0) setCredentialId(providerCredentials[0].id) 1535 } 1536 }} 1537 className={`flex-1 py-3 rounded-[12px] text-center cursor-pointer transition-all duration-200 1538 text-[14px] font-600 capitalize 1539 ${ollamaMode === mode 1540 ? 'bg-accent-soft text-accent-bright shadow-[0_0_20px_rgba(99,102,241,0.1)]' 1541 : 'bg-transparent text-text-3 hover:text-text-2'}`} 1542 style={{ fontFamily: 'inherit' }} 1543 > 1544 {mode} 1545 </button> 1546 ))} 1547 </div> 1548 </div> 1549 )} 1550 1551 {!openclawEnabled && (currentProvider?.requiresApiKey || currentProvider?.optionalApiKey || (provider === 'ollama' && ollamaMode === 'cloud')) && ( 1552 <div className="mb-8"> 1553 <SectionLabel>API Key{currentProvider?.optionalApiKey && !currentProvider?.requiresApiKey && <span className="normal-case tracking-normal font-normal text-text-3"> (optional)</span>}</SectionLabel> 1554 {providerCredentials.length > 0 && !addingKey ? ( 1555 <div className="flex gap-2"> 1556 <select value={credentialId || ''} onChange={(e) => { 1557 if (e.target.value === '__add__') { 1558 setAddingKey(true) 1559 setNewKeyName('') 1560 setNewKeyValue('') 1561 } else { 1562 setCredentialId(e.target.value || null) 1563 } 1564 }} className={`${inputClass} appearance-none cursor-pointer flex-1`} style={{ fontFamily: 'inherit' }}> 1565 <option value="">Select a key...</option> 1566 {providerCredentials.map((c) => ( 1567 <option key={c.id} value={c.id}>{c.name}</option> 1568 ))} 1569 <option value="__add__">+ Add new key...</option> 1570 </select> 1571 <button 1572 type="button" 1573 onClick={() => { setAddingKey(true); setNewKeyName(''); setNewKeyValue('') }} 1574 className="shrink-0 px-3 py-2.5 rounded-[10px] bg-accent-soft/50 text-accent-bright text-[12px] font-600 hover:bg-accent-soft transition-colors cursor-pointer border border-accent-bright/20" 1575 > 1576 + New 1577 </button> 1578 </div> 1579 ) : ( 1580 <div className="space-y-3 p-4 rounded-[12px] border border-accent-bright/15 bg-accent-soft/20"> 1581 <input 1582 type="text" 1583 value={newKeyName} 1584 onChange={(e) => setNewKeyName(e.target.value)} 1585 placeholder="Key name (optional)" 1586 className={inputClass} 1587 style={{ fontFamily: 'inherit' }} 1588 /> 1589 <input 1590 type="password" 1591 value={newKeyValue} 1592 onChange={(e) => setNewKeyValue(e.target.value)} 1593 placeholder="Paste API key..." 1594 className={inputClass} 1595 style={{ fontFamily: 'inherit' }} 1596 /> 1597 <div className="flex gap-2 justify-end"> 1598 {providerCredentials.length > 0 && ( 1599 <button type="button" onClick={() => setAddingKey(false)} className="px-3 py-1.5 text-[12px] text-text-3 hover:text-text-2 transition-colors cursor-pointer bg-transparent border-none" style={{ fontFamily: 'inherit' }}>Cancel</button> 1600 )} 1601 <button 1602 type="button" 1603 disabled={savingKey || !newKeyValue.trim()} 1604 onClick={async () => { 1605 setSavingKey(true) 1606 try { 1607 const cred = await api<{ id: string }>('POST', '/credentials', { provider, name: newKeyName.trim() || `${provider} key`, apiKey: newKeyValue.trim() }) 1608 await loadCredentials() 1609 setCredentialId(cred.id) 1610 const synced = await syncLiveProviderModels(provider, cred.id, apiEndpoint, ollamaMode, true).catch(() => null) 1611 setAddingKey(false) 1612 setNewKeyName('') 1613 setNewKeyValue('') 1614 if (synced?.models.length) { 1615 toast.success(`Key saved. Synced ${synced.models.length} model${synced.models.length === 1 ? '' : 's'}.`) 1616 } else { 1617 toast.success('Key saved') 1618 } 1619 } catch (err: unknown) { toast.error(`Failed to save: ${errorMessage(err)}`) } 1620 finally { setSavingKey(false) } 1621 }} 1622 className="px-4 py-1.5 rounded-[8px] bg-accent-bright text-white text-[12px] font-600 cursor-pointer border-none hover:brightness-110 transition-all disabled:opacity-40" 1623 style={{ fontFamily: 'inherit' }} 1624 > 1625 {savingKey ? 'Saving...' : 'Save Key'} 1626 </button> 1627 </div> 1628 </div> 1629 )} 1630 </div> 1631 )} 1632 1633 {/* Fallback Credentials */} 1634 {!openclawEnabled && (currentProvider?.requiresApiKey || currentProvider?.optionalApiKey || (provider === 'ollama' && ollamaMode === 'cloud')) && providerCredentials.length > 1 && ( 1635 <div className="mb-8"> 1636 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 1637 Fallback Keys <span className="normal-case tracking-normal font-normal text-text-3">(for auto-failover)</span> 1638 </label> 1639 <p className="text-[12px] text-text-3/60 mb-3">If the primary key fails (rate limit, auth error), these keys will be tried in order.</p> 1640 <div className="flex flex-wrap gap-2"> 1641 {providerCredentials.filter((c) => c.id !== credentialId).map((c) => { 1642 const active = fallbackCredentialIds.includes(c.id) 1643 return ( 1644 <button 1645 key={c.id} 1646 onClick={() => setFallbackCredentialIds((prev) => active ? prev.filter((x) => x !== c.id) : [...prev, c.id])} 1647 className={`px-3 py-2 rounded-[10px] text-[12px] font-600 cursor-pointer transition-all border 1648 ${active 1649 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 1650 : 'bg-surface border-white/[0.06] text-text-3 hover:text-text-2'}`} 1651 style={{ fontFamily: 'inherit' }} 1652 > 1653 {c.name} 1654 </button> 1655 ) 1656 })} 1657 </div> 1658 </div> 1659 )} 1660 1661 {(currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint) && (provider !== 'ollama' || ollamaMode === 'local') && ( 1662 <div className="mb-8"> 1663 <SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : 'Endpoint'}</SectionLabel> 1664 <input type="text" value={apiEndpoint || ''} onChange={(e) => setApiEndpoint(e.target.value || null)} placeholder={currentProvider.defaultEndpoint || 'http://localhost:11434'} className={`${inputClass} font-mono text-[14px]`} /> 1665 {provider === 'openclaw' && ( 1666 <p className="text-[13px] text-text-3/70 mt-2">The URL of your OpenClaw gateway</p> 1667 )} 1668 {provider === 'hermes' && ( 1669 <p className="text-[13px] text-text-3/70 mt-2">Point this at the Hermes API server, usually <code className="text-text-2">http://127.0.0.1:8642/v1</code>.</p> 1670 )} 1671 </div> 1672 )} 1673 1674 </SectionCard> 1675 1676 <SectionCard 1677 title="Instructions" 1678 description="Keep the agent's personality and core prompt visible and easy to edit." 1679 > 1680 <div className="mb-8"> 1681 <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 1682 Soul / Personality <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span> 1683 <HintTip text="The agent's voice and tone — how it talks, not what it knows" /> 1684 {soul !== soulInitial && soulSaveState === 'idle' && ( 1685 <span className="inline-flex items-center gap-1 normal-case tracking-normal text-[10px] text-amber-400 font-600"> 1686 <StatusDot status="warning" size="sm" /> 1687 Unsaved 1688 </span> 1689 )} 1690 {soulSaveState === 'saved' && ( 1691 <span className="inline-flex items-center gap-1 normal-case tracking-normal text-[10px] text-emerald-400 font-600"> 1692 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12" /></svg> 1693 Saved 1694 </span> 1695 )} 1696 </label> 1697 <div className="mb-3 flex flex-wrap items-center gap-2"> 1698 <p className="text-[12px] text-text-3/60">Define the agent's voice, tone, and personality. Injected before the system prompt.</p> 1699 <button 1700 type="button" 1701 onClick={() => setSoul(randomSoul())} 1702 className="inline-flex items-center gap-1.5 shrink-0 px-2 py-1 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] text-text-3 hover:text-text-2 cursor-pointer transition-colors" 1703 style={{ fontFamily: 'inherit' }} 1704 title="Randomize personality" 1705 > 1706 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 1707 <rect x="4" y="4" width="16" height="16" rx="2" /> 1708 <circle cx="9" cy="9" r="1" fill="currentColor" /> 1709 <circle cx="15" cy="15" r="1" fill="currentColor" /> 1710 </svg> 1711 Shuffle 1712 </button> 1713 <button 1714 type="button" 1715 onClick={() => setSoulLibraryOpen(true)} 1716 className="shrink-0 px-2 py-1 rounded-[8px] border border-accent-bright/20 bg-accent-soft text-[11px] text-accent-bright hover:brightness-110 cursor-pointer transition-colors" 1717 style={{ fontFamily: 'inherit' }} 1718 > 1719 Browse Library 1720 </button> 1721 <button onClick={() => soulFileRef.current?.click()} className="shrink-0 px-2 py-1 rounded-[8px] border border-white/[0.08] bg-surface text-[11px] text-text-3 hover:text-text-2 cursor-pointer transition-colors" style={{ fontFamily: 'inherit' }}>Upload .md</button> 1722 <input ref={soulFileRef} type="file" accept=".md,.txt,.markdown" onChange={handleFileUpload(setSoul)} className="hidden" /> 1723 </div> 1724 <textarea 1725 value={soul} 1726 onChange={(e) => setSoul(e.target.value)} 1727 placeholder="e.g. You speak concisely and directly. You have a dry sense of humor. You always back claims with data." 1728 rows={3} 1729 className={`${inputClass} resize-y min-h-[80px]`} 1730 style={{ fontFamily: 'inherit' }} 1731 /> 1732 </div> 1733 1734 {provider !== 'openclaw' ? ( 1735 <div className="mb-1"> 1736 <div className="mb-3 flex items-center gap-2"> 1737 <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">System Prompt <HintTip text="Instructions that tell the agent what it can do, what tools to use, and how to behave" /></label> 1738 <button onClick={() => promptFileRef.current?.click()} className="shrink-0 px-2 py-1 rounded-[8px] border border-white/[0.08] bg-surface text-[11px] text-text-3 hover:text-text-2 cursor-pointer transition-colors" style={{ fontFamily: 'inherit' }}>Upload .md</button> 1739 <input ref={promptFileRef} type="file" accept=".md,.txt,.markdown" onChange={handleFileUpload(setSystemPrompt)} className="hidden" /> 1740 </div> 1741 <textarea 1742 value={systemPrompt} 1743 onChange={(e) => setSystemPrompt(e.target.value)} 1744 placeholder="You are an expert..." 1745 rows={6} 1746 className={`${inputClass} resize-y min-h-[140px]`} 1747 style={{ fontFamily: 'inherit' }} 1748 /> 1749 </div> 1750 ) : ( 1751 <div className="rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 text-[13px] leading-[1.6] text-text-3"> 1752 OpenClaw agents rely on the gateway runtime for tool execution and node routing. Expand advanced settings if you need continuity, voice, or heartbeat overrides. 1753 </div> 1754 )} 1755 </SectionCard> 1756 1757 {(!WORKER_ONLY_PROVIDER_IDS.has(provider) || isOrchestratorProviderEligible(provider)) && ( 1758 <SectionCard 1759 title="Role & Autonomy" 1760 description="Define how this agent operates in the swarm." 1761 > 1762 {/* --- Role subsection --- */} 1763 {!WORKER_ONLY_PROVIDER_IDS.has(provider) && ( 1764 <div className="rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 mb-4"> 1765 <div className="flex items-center gap-2 mb-3"> 1766 <SectionLabel>Role</SectionLabel> 1767 <HintTip text="Coordinators automatically receive a list of available agents and can decompose complex goals, delegate to specialists, and synthesize results." /> 1768 </div> 1769 <div className="flex gap-2 mb-3"> 1770 {(['worker', 'coordinator'] as const).map((r) => ( 1771 <button 1772 key={r} 1773 type="button" 1774 onClick={() => { 1775 setRole(r) 1776 if (r === 'coordinator') setDelegationEnabled(true) 1777 }} 1778 className={`px-4 py-1.5 rounded-[8px] text-[13px] font-display font-500 transition-all duration-200 1779 ${role === r 1780 ? 'bg-accent-bright text-white' 1781 : 'bg-white/[0.06] text-text-3 hover:bg-white/[0.10]'}`} 1782 > 1783 {r === 'worker' ? 'Worker' : 'Coordinator'} 1784 </button> 1785 ))} 1786 </div> 1787 <p className="text-[12px] text-text-3/75"> 1788 {role === 'coordinator' 1789 ? 'Breaks down complex goals, delegates to specialists, and synthesizes results' 1790 : 'Executes tasks when prompted by users or other agents'} 1791 </p> 1792 1793 {/* Delegation toggle */} 1794 <div className="mt-4"> 1795 <label className="flex items-center gap-3 cursor-pointer"> 1796 <div 1797 onClick={() => { 1798 if (role !== 'coordinator') setDelegationEnabled((current) => !current) 1799 }} 1800 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 1801 ${canDelegateToAgents ? 'bg-accent-bright' : 'bg-white/[0.08]'} 1802 ${role === 'coordinator' ? 'opacity-60' : ''}`} 1803 > 1804 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 1805 ${canDelegateToAgents ? 'left-[22px]' : 'left-0.5'}`} /> 1806 </div> 1807 <span className="font-display text-[14px] font-600 text-text-2">Can Delegate</span> 1808 <span className="text-[12px] text-text-3"> 1809 {role === 'coordinator' ? 'Always on for coordinators' : 'Route work to specialized agents'} 1810 </span> 1811 </label> 1812 </div> 1813 1814 {/* Delegation targets */} 1815 {canDelegateToAgents && agentOptions.length > 0 && ( 1816 <div className="mt-4"> 1817 <SectionLabel>Allowed Delegate Agents</SectionLabel> 1818 <AgentPickerList 1819 agents={agentOptions} 1820 selected={delegationTargetMode === 'all' ? [] : delegationTargetAgentIds} 1821 onSelect={(id) => toggleAgent(id)} 1822 noneOption={{ 1823 label: 'All Agents', 1824 onSelect: () => { 1825 setDelegationTargetMode('all') 1826 setDelegationTargetAgentIds([]) 1827 }, 1828 }} 1829 /> 1830 </div> 1831 )} 1832 </div> 1833 )} 1834 1835 {/* --- Orchestrator subsection --- */} 1836 {isOrchestratorProviderEligible(provider) && ( 1837 <div className="rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4"> 1838 <div className="flex items-center justify-between gap-4"> 1839 <div className="min-w-0"> 1840 <p className="text-[14px] font-600 text-text">Orchestrator Mode</p> 1841 <p className="mt-1 text-[12px] leading-[1.6] text-text-3/75"> 1842 Wakes on a schedule to autonomously review platform state and take action. 1843 </p> 1844 </div> 1845 <button 1846 type="button" 1847 onClick={() => setOrchestratorEnabled((current) => !current)} 1848 className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${orchestratorEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`} 1849 aria-pressed={orchestratorEnabled} 1850 > 1851 <span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${orchestratorEnabled ? 'translate-x-5' : 'translate-x-0'}`} /> 1852 </button> 1853 </div> 1854 1855 {orchestratorEnabled && ( 1856 <div className="mt-4 space-y-4"> 1857 <div> 1858 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 1859 Mission 1860 </label> 1861 <textarea 1862 value={orchestratorMission} 1863 onChange={(e) => setOrchestratorMission(e.target.value)} 1864 placeholder="Describe the orchestrator's mission — what should it manage, optimize, or oversee?" 1865 rows={3} 1866 className={`${inputClass} resize-y min-h-[84px]`} 1867 style={{ fontFamily: 'inherit' }} 1868 /> 1869 </div> 1870 1871 <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> 1872 <div> 1873 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 1874 Wake Interval 1875 </label> 1876 <input 1877 type="text" 1878 value={orchestratorWakeInterval} 1879 onChange={(e) => setOrchestratorWakeInterval(e.target.value)} 1880 placeholder="5m" 1881 className={inputClass} 1882 style={{ fontFamily: 'inherit' }} 1883 /> 1884 </div> 1885 <div> 1886 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 1887 Governance 1888 </label> 1889 <select 1890 value={orchestratorGovernance} 1891 onChange={(e) => setOrchestratorGovernance(e.target.value as typeof orchestratorGovernance)} 1892 className={inputClass} 1893 style={{ fontFamily: 'inherit' }} 1894 > 1895 <option value="autonomous">Autonomous</option> 1896 <option value="approval-required">Approval Required</option> 1897 <option value="notify-only">Notify Only</option> 1898 </select> 1899 </div> 1900 <div> 1901 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 1902 Max Cycles/Day 1903 </label> 1904 <input 1905 type="number" 1906 value={orchestratorMaxCyclesPerDay} 1907 onChange={(e) => setOrchestratorMaxCyclesPerDay(e.target.value)} 1908 placeholder="No limit" 1909 min={1} 1910 className={inputClass} 1911 style={{ fontFamily: 'inherit' }} 1912 /> 1913 </div> 1914 </div> 1915 </div> 1916 )} 1917 </div> 1918 )} 1919 </SectionCard> 1920 )} 1921 1922 {!WORKER_ONLY_PROVIDER_IDS.has(provider) && ( 1923 <SectionCard 1924 title="Behavior" 1925 description="Keep the core autonomy switch visible. Expert heartbeat controls stay in advanced settings." 1926 > 1927 <div className="flex items-center justify-between gap-4 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4"> 1928 <div className="min-w-0"> 1929 <p className="text-[14px] font-600 text-text">Heartbeat</p> 1930 <p className="mt-1 text-[12px] leading-[1.6] text-text-3/75"> 1931 Keep this agent alive in the background for proactive work and scheduled follow-through. 1932 </p> 1933 </div> 1934 <button 1935 type="button" 1936 onClick={() => setHeartbeatEnabled((current) => !current)} 1937 className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${heartbeatEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`} 1938 aria-pressed={heartbeatEnabled} 1939 > 1940 <span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${heartbeatEnabled ? 'translate-x-5' : 'translate-x-0'}`} /> 1941 </button> 1942 </div> 1943 <div className="flex items-center justify-between gap-4 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 mt-3"> 1944 <div className="min-w-0"> 1945 <div className="flex items-center gap-2"> 1946 <p className="text-[14px] font-600 text-text">Dreaming</p> 1947 <HintTip text="When enabled, this agent consolidates and optimizes its memories during idle periods" /> 1948 </div> 1949 <p className="mt-1 text-[12px] leading-[1.6] text-text-3/75"> 1950 Consolidate, decay, and reflect on memories when the agent is idle. 1951 </p> 1952 </div> 1953 <button 1954 type="button" 1955 onClick={() => setDreamEnabled((current) => !current)} 1956 className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${dreamEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`} 1957 aria-pressed={dreamEnabled} 1958 > 1959 <span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${dreamEnabled ? 'translate-x-5' : 'translate-x-0'}`} /> 1960 </button> 1961 </div> 1962 {dreamEnabled && ( 1963 <div className="mt-3 rounded-[14px] border border-white/[0.04] bg-white/[0.01] px-4 py-4 space-y-3"> 1964 <div> 1965 <label className="flex items-center gap-2 text-[12px] font-600 text-text-2 mb-1.5"> 1966 Cooldown (minutes) <HintTip text="Minimum minutes between dream cycles" /> 1967 </label> 1968 <input 1969 type="number" 1970 value={dreamCooldownMinutes} 1971 onChange={(e) => setDreamCooldownMinutes(e.target.value)} 1972 min={1} 1973 placeholder="360" 1974 className={inputClass} 1975 style={{ fontFamily: 'inherit' }} 1976 /> 1977 </div> 1978 <label className="flex items-center gap-3 cursor-pointer"> 1979 <div 1980 onClick={() => setDreamTier2Enabled((current) => !current)} 1981 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${dreamTier2Enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 1982 > 1983 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${dreamTier2Enabled ? 'left-[22px]' : 'left-0.5'}`} /> 1984 </div> 1985 <span className="flex items-center gap-2 text-[13px] text-text-2"> 1986 Tier 2 Reflection <HintTip text="Use the agent's LLM to reflect on memories and produce consolidated insights" /> 1987 </span> 1988 </label> 1989 </div> 1990 )} 1991 </SectionCard> 1992 )} 1993 1994 {editing && ( 1995 <SectionCard 1996 title="Social Network" 1997 description="SwarmFeed integration — let this agent post and engage on the social feed." 1998 > 1999 <AgentSocialSettings agent={editing} /> 2000 </SectionCard> 2001 )} 2002 2003 {editing && ( 2004 <SectionCard 2005 title="Marketplace" 2006 description="SwarmDock integration — list this agent on the AI marketplace to accept tasks and earn USDC." 2007 > 2008 <AgentMarketplaceSettings agent={editing} /> 2009 </SectionCard> 2010 )} 2011 2012 {(!WORKER_ONLY_PROVIDER_IDS.has(provider) || MCP_INJECTION_PROVIDER_IDS.has(provider)) && ( 2013 <AdvancedSettingsSection 2014 open={showAdvancedSettings} 2015 onToggle={() => setShowAdvancedSettings((current) => !current)} 2016 summary={advancedSummary} 2017 badges={agentAdvancedBadges} 2018 > 2019 {!WORKER_ONLY_PROVIDER_IDS.has(provider) && (<> 2020 <SectionCard 2021 title="Context & Tool Access" 2022 description="Control how many tools are described in this agent's system prompt. Scoped (default) keeps the agent focused and saves ~3 k input tokens per turn; Universal gives it visibility into every built-in tool." 2023 className="mb-6 border-white/[0.05] bg-white/[0.01]" 2024 > 2025 <div className="space-y-3"> 2026 <label className="flex items-center gap-3 cursor-pointer"> 2027 <div 2028 onClick={() => setToolAccessMode((current) => current === 'universal' ? 'scoped' : 'universal')} 2029 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${toolAccessMode === 'universal' ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 2030 > 2031 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${toolAccessMode === 'universal' ? 'left-[22px]' : 'left-0.5'}`} /> 2032 </div> 2033 <span className="text-[13px] text-text-2">Universal tool access</span> 2034 <HintTip text="Off (default, recommended): the agent only sees tools enabled in its Tools list. On: every built-in tool is described in the system prompt. Turn on only for coordinator agents that need visibility across every possible downstream tool, or temporarily for debugging." /> 2035 </label> 2036 <p className="text-[12px] text-text-3/70 pl-[56px] -mt-1"> 2037 {toolAccessMode === 'universal' 2038 ? 'Full tool universe is injected into the prompt. Costs ~3 k more input tokens per turn.' 2039 : 'Only the tools enabled above are visible to the agent — this is the focused default.'} 2040 </p> 2041 </div> 2042 </SectionCard> 2043 2044 <SectionCard 2045 title="Voice & Autonomy" 2046 description="Tune voice and the detailed heartbeat behavior for this agent." 2047 className="mb-6 border-white/[0.05] bg-white/[0.01]" 2048 > 2049 <div className="mb-8"> 2050 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2051 Voice & Audio 2052 </label> 2053 {voiceControlsAvailable ? ( 2054 <> 2055 <div className="grid grid-cols-1 md:grid-cols-[minmax(0,1fr)_auto] gap-3"> 2056 <input 2057 type="text" 2058 value={voiceId} 2059 onChange={(e) => setVoiceId(e.target.value)} 2060 placeholder="ElevenLabs voice ID" 2061 className={inputClass} 2062 style={{ fontFamily: 'inherit' }} 2063 /> 2064 <button 2065 type="button" 2066 onClick={() => setVoiceId('')} 2067 className="px-3 py-2.5 rounded-[10px] border border-white/[0.08] bg-transparent text-[12px] font-600 text-text-3 hover:bg-white/[0.04] hover:text-text-2 transition-all cursor-pointer" 2068 style={{ fontFamily: 'inherit' }} 2069 > 2070 Use global default 2071 </button> 2072 </div> 2073 <p className="mt-2 text-[12px] leading-[1.6] text-text-3/70"> 2074 Current effective voice: <span className="text-text-2">{effectiveVoiceId}</span> · {effectiveVoiceSource} 2075 {!voicePlaybackEnabled && ' · Voice playback is disabled globally'} 2076 </p> 2077 </> 2078 ) : ( 2079 <p className="text-[12px] leading-[1.6] text-text-3/70"> 2080 ElevenLabs is not configured yet. Add a global API key in Settings to enable voice overrides here. 2081 </p> 2082 )} 2083 </div> 2084 2085 <div> 2086 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2087 Heartbeat Controls 2088 </label> 2089 <div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3"> 2090 <select 2091 value={heartbeatIntervalSec} 2092 onChange={(e) => setHeartbeatIntervalSec(e.target.value)} 2093 className={inputClass} 2094 style={{ fontFamily: 'inherit' }} 2095 > 2096 <option value="">Use default interval</option> 2097 {HB_PRESETS.map((preset) => ( 2098 <option key={preset} value={preset}>{formatHbDuration(preset)}</option> 2099 ))} 2100 </select> 2101 <input 2102 type="text" 2103 value={heartbeatModel} 2104 onChange={(e) => setHeartbeatModel(e.target.value)} 2105 placeholder="Heartbeat model override" 2106 className={inputClass} 2107 style={{ fontFamily: 'inherit' }} 2108 /> 2109 </div> 2110 <textarea 2111 value={heartbeatPrompt} 2112 onChange={(e) => setHeartbeatPrompt(e.target.value)} 2113 placeholder="Optional custom heartbeat prompt" 2114 rows={3} 2115 className={`${inputClass} resize-y min-h-[84px]`} 2116 style={{ fontFamily: 'inherit' }} 2117 /> 2118 </div> 2119 </SectionCard> 2120 2121 <SectionCard 2122 title="Memory & Intelligence" 2123 description="Reasoning depth, memory defaults, and drafting behavior." 2124 className="mb-6 border-white/[0.05] bg-white/[0.01]" 2125 > 2126 <div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4"> 2127 <select value={thinkingLevel} onChange={(e) => setThinkingLevel(e.target.value as typeof thinkingLevel)} className={inputClass}> 2128 <option value="">Default thinking</option> 2129 <option value="minimal">Minimal</option> 2130 <option value="low">Low</option> 2131 <option value="medium">Medium</option> 2132 <option value="high">High</option> 2133 </select> 2134 <select value={memoryScopeMode} onChange={(e) => setMemoryScopeMode(e.target.value as typeof memoryScopeMode)} className={inputClass}> 2135 <option value="auto">Auto memory scope</option> 2136 <option value="all">All</option> 2137 <option value="global">Global</option> 2138 <option value="agent">Agent</option> 2139 <option value="session">Session</option> 2140 <option value="project">Project</option> 2141 </select> 2142 <select value={memoryTierPreference} onChange={(e) => setMemoryTierPreference(e.target.value as typeof memoryTierPreference)} className={inputClass}> 2143 <option value="blended">Blended tiering</option> 2144 <option value="working">Working memory</option> 2145 <option value="durable">Durable memory</option> 2146 <option value="archive">Archive memory</option> 2147 </select> 2148 </div> 2149 <div className="space-y-3"> 2150 <label className="flex items-center gap-3 cursor-pointer"> 2151 <div 2152 onClick={() => setProactiveMemory((current) => !current)} 2153 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${proactiveMemory ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 2154 > 2155 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${proactiveMemory ? 'left-[22px]' : 'left-0.5'}`} /> 2156 </div> 2157 <span className="text-[13px] text-text-2">Use proactive recall before each run</span> 2158 </label> 2159 <label className="flex items-center gap-3 cursor-pointer"> 2160 <div 2161 onClick={() => setAutoDraftSkillSuggestions((current) => !current)} 2162 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${autoDraftSkillSuggestions ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 2163 > 2164 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${autoDraftSkillSuggestions ? 'left-[22px]' : 'left-0.5'}`} /> 2165 </div> 2166 <span className="text-[13px] text-text-2">Auto-draft conversation skills</span> 2167 </label> 2168 </div> 2169 </SectionCard> 2170 2171 <SectionCard 2172 title="Continuity" 2173 description="Stable identity, relationship context, and session reset policy." 2174 className="mb-6 border-white/[0.05] bg-white/[0.01]" 2175 > 2176 <div className="mb-8"> 2177 <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2178 Identity Continuity <HintTip text="Seeds the agent's continuity state so session memory can preserve a stable persona and relationship context." /> 2179 </label> 2180 <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3"> 2181 <input type="text" value={identityPersonaLabel} onChange={(e) => setIdentityPersonaLabel(e.target.value)} placeholder="Persona label" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2182 <input type="text" value={identityToneStyle} onChange={(e) => setIdentityToneStyle(e.target.value)} placeholder="Tone style" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2183 </div> 2184 <div className="grid grid-cols-1 gap-3"> 2185 <textarea value={identitySelfSummary} onChange={(e) => setIdentitySelfSummary(e.target.value)} placeholder="How this agent should summarize itself across sessions." rows={3} className={`${inputClass} resize-y min-h-[84px]`} style={{ fontFamily: 'inherit' }} /> 2186 <textarea value={identityRelationshipSummary} onChange={(e) => setIdentityRelationshipSummary(e.target.value)} placeholder="Relationship framing or standing context the agent should keep in mind." rows={3} className={`${inputClass} resize-y min-h-[84px]`} style={{ fontFamily: 'inherit' }} /> 2187 <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 2188 <textarea value={identityBoundariesText} onChange={(e) => setIdentityBoundariesText(e.target.value)} placeholder="Boundaries, one per line." rows={4} className={`${inputClass} resize-y min-h-[108px]`} style={{ fontFamily: 'inherit' }} /> 2189 <textarea value={identityContinuityNotesText} onChange={(e) => setIdentityContinuityNotesText(e.target.value)} placeholder="Continuity notes, one per line." rows={4} className={`${inputClass} resize-y min-h-[108px]`} style={{ fontFamily: 'inherit' }} /> 2190 </div> 2191 </div> 2192 <p className="mt-2 text-[12px] leading-[1.5] text-text-3/60"> 2193 Use one line per item. Boundaries are stable guardrails; continuity notes are recurring relationship or project context worth carrying across sessions. 2194 </p> 2195 </div> 2196 2197 <div> 2198 <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2199 Session Reset Policy <HintTip text="Controls when this agent's sessions are considered stale and should be refreshed." /> 2200 </label> 2201 <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3"> 2202 <select value={sessionResetMode} onChange={(e) => setSessionResetMode(e.target.value as typeof sessionResetMode)} className={inputClass} style={{ fontFamily: 'inherit' }}> 2203 <option value="">Inherit global default</option> 2204 <option value="idle">Idle</option> 2205 <option value="daily">Daily</option> 2206 <option value="isolated">Isolated (fresh context per run)</option> 2207 </select> 2208 <input type="number" min={0} value={sessionIdleTimeoutSec} onChange={(e) => setSessionIdleTimeoutSec(e.target.value)} placeholder="Idle timeout in seconds" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2209 </div> 2210 <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> 2211 <input type="number" min={0} value={sessionMaxAgeSec} onChange={(e) => setSessionMaxAgeSec(e.target.value)} placeholder="Max age in seconds" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2212 <input type="text" value={sessionDailyResetAt} onChange={(e) => setSessionDailyResetAt(e.target.value)} placeholder="Daily reset time (HH:MM)" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2213 <input type="text" value={sessionResetTimezone} onChange={(e) => setSessionResetTimezone(e.target.value)} placeholder="Timezone (optional)" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2214 </div> 2215 </div> 2216 </SectionCard> 2217 2218 <SectionCard 2219 title="Routing & Infrastructure" 2220 description="Project binding, filesystem access, and other deeper runtime controls." 2221 className="mb-6 border-white/[0.05] bg-white/[0.01]" 2222 > 2223 {Object.keys(projects).length > 0 && ( 2224 <div className="mb-8"> 2225 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Project</label> 2226 <select value={projectId || ''} onChange={(e) => setProjectId(e.target.value || undefined)} className={inputClass} style={{ fontFamily: 'inherit' }}> 2227 <option value="">No project</option> 2228 {Object.values(projects).map((project) => ( 2229 <option key={project.id} value={project.id}>{project.name}</option> 2230 ))} 2231 </select> 2232 </div> 2233 )} 2234 {openclawEnabled && ( 2235 <div className="mb-8"> 2236 <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2237 Gateway Preferences <HintTip text="When multiple OpenClaw gateways are available, prefer matching tags or deployment templates before falling back to the default route." /> 2238 </label> 2239 <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 2240 <input 2241 type="text" 2242 value={preferredGatewayTagsText} 2243 onChange={(e) => setPreferredGatewayTagsText(e.target.value)} 2244 placeholder="gpu, local, research" 2245 className={inputClass} 2246 /> 2247 <select value={preferredGatewayUseCase} onChange={(e) => setPreferredGatewayUseCase(e.target.value)} className={inputClass}> 2248 <option value="">Any OpenClaw template</option> 2249 <option value="local-dev">Local Dev</option> 2250 <option value="single-vps">Single VPS</option> 2251 <option value="private-tailnet">Private Tailnet</option> 2252 <option value="browser-heavy">Browser Heavy</option> 2253 <option value="team-control">Team Control</option> 2254 </select> 2255 </div> 2256 <p className="text-[11px] text-text-3/70 mt-2"> 2257 These preferences bias scheduling toward matching OpenClaw control planes without hard-locking the agent to one gateway. 2258 </p> 2259 </div> 2260 )} 2261 <div className="mb-8"> 2262 <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2263 Model Routing <HintTip text="Route this agent through a provider/model pool instead of a single fixed model. The base provider remains the default when no route matches." /> 2264 </label> 2265 <div className="flex items-center gap-3 mb-3"> 2266 <select value={routingStrategy} onChange={(e) => setRoutingStrategy(e.target.value as AgentRoutingStrategy)} className={inputClass}> 2267 <option value="single">Single route</option> 2268 <option value="balanced">Balanced</option> 2269 <option value="economy">Economy</option> 2270 <option value="premium">Premium</option> 2271 <option value="reasoning">Reasoning</option> 2272 </select> 2273 <button 2274 type="button" 2275 onClick={addRoutingTargetFromCurrent} 2276 className="shrink-0 px-3 py-2.5 rounded-[10px] bg-accent-soft/50 text-accent-bright text-[12px] font-700 hover:bg-accent-soft transition-colors cursor-pointer border border-accent-bright/20" 2277 > 2278 + Add Current Route 2279 </button> 2280 </div> 2281 <div className="space-y-3"> 2282 {routingTargets.map((target, index) => { 2283 const targetCredentials = resolveAgentSelectableProviderCredentials(target.provider, credentials, providerConfigs) 2284 return ( 2285 <div key={target.id} className="p-4 rounded-[12px] border border-white/[0.08] bg-white/[0.02] space-y-3"> 2286 <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 2287 <input 2288 value={target.label || ''} 2289 onChange={(e) => updateRoutingTarget(target.id, { label: e.target.value })} 2290 placeholder={`Route ${index + 1} label`} 2291 className={inputClass} 2292 /> 2293 <select value={target.role || 'backup'} onChange={(e) => updateRoutingTarget(target.id, { role: e.target.value as AgentRoutingTarget['role'] })} className={inputClass}> 2294 <option value="primary">Primary</option> 2295 <option value="economy">Economy</option> 2296 <option value="premium">Premium</option> 2297 <option value="reasoning">Reasoning</option> 2298 <option value="backup">Backup</option> 2299 </select> 2300 </div> 2301 <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 2302 <select 2303 value={target.provider} 2304 onChange={(e) => { 2305 const nextProviderId = e.target.value 2306 const nextCredentials = resolveAgentSelectableProviderCredentials(nextProviderId, credentials, providerConfigs) 2307 updateRoutingTarget(target.id, { 2308 provider: nextProviderId, 2309 credentialId: nextCredentials[0]?.id || null, 2310 gatewayProfileId: nextProviderId === 'openclaw' ? target.gatewayProfileId : null, 2311 ollamaMode: nextProviderId === 'ollama' 2312 ? resolveStoredOllamaMode({ 2313 ollamaMode: target.ollamaMode ?? null, 2314 apiEndpoint: target.apiEndpoint ?? null, 2315 }) 2316 : null, 2317 }) 2318 }} 2319 className={inputClass} 2320 > 2321 {agentSelectableProviders.map((item) => ( 2322 <option key={item.id} value={item.id}>{item.name}</option> 2323 ))} 2324 </select> 2325 <input 2326 value={target.model} 2327 onChange={(e) => updateRoutingTarget(target.id, { model: e.target.value })} 2328 placeholder="Model" 2329 className={inputClass} 2330 /> 2331 </div> 2332 {target.provider === 'openclaw' && openclawGatewayProfiles.length > 0 && ( 2333 <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> 2334 <select 2335 value={target.gatewayProfileId || ''} 2336 onChange={(e) => { 2337 const nextId = e.target.value || null 2338 const gateway = openclawGatewayProfiles.find((item) => item.id === nextId) 2339 updateRoutingTarget(target.id, { 2340 gatewayProfileId: nextId, 2341 apiEndpoint: gateway?.endpoint || target.apiEndpoint || null, 2342 credentialId: gateway?.credentialId || target.credentialId || null, 2343 model: target.model || 'default', 2344 }) 2345 }} 2346 className={inputClass} 2347 > 2348 <option value="">Custom OpenClaw endpoint</option> 2349 {openclawGatewayProfiles.map((gateway) => ( 2350 <option key={gateway.id} value={gateway.id}>{gateway.name}</option> 2351 ))} 2352 </select> 2353 <input 2354 value={formatGatewayTagList(target.preferredGatewayTags)} 2355 onChange={(e) => updateRoutingTarget(target.id, { preferredGatewayTags: parseGatewayTagList(e.target.value) })} 2356 placeholder="Prefer tags" 2357 className={inputClass} 2358 /> 2359 <select 2360 value={target.preferredGatewayUseCase || ''} 2361 onChange={(e) => updateRoutingTarget(target.id, { preferredGatewayUseCase: e.target.value || null })} 2362 className={inputClass} 2363 > 2364 <option value="">Any OpenClaw template</option> 2365 <option value="local-dev">Local Dev</option> 2366 <option value="single-vps">Single VPS</option> 2367 <option value="private-tailnet">Private Tailnet</option> 2368 <option value="browser-heavy">Browser Heavy</option> 2369 <option value="team-control">Team Control</option> 2370 </select> 2371 </div> 2372 )} 2373 <div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3"> 2374 <input 2375 value={target.apiEndpoint || ''} 2376 onChange={(e) => updateRoutingTarget(target.id, { apiEndpoint: e.target.value || null })} 2377 placeholder="Endpoint (optional)" 2378 className={`${inputClass} font-mono text-[14px]`} 2379 /> 2380 <select value={target.credentialId || ''} onChange={(e) => updateRoutingTarget(target.id, { credentialId: e.target.value || null })} className={inputClass}> 2381 <option value="">No key</option> 2382 {targetCredentials.map((item) => ( 2383 <option key={item.id} value={item.id}>{item.name}</option> 2384 ))} 2385 </select> 2386 </div> 2387 <div className="flex justify-end"> 2388 <button type="button" onClick={() => removeRoutingTarget(target.id)} className="px-3 py-1.5 rounded-[8px] border border-red-400/20 bg-red-400/[0.06] text-[12px] font-700 text-red-300 hover:bg-red-400/[0.1] transition-all cursor-pointer"> 2389 Remove Route 2390 </button> 2391 </div> 2392 </div> 2393 ) 2394 })} 2395 </div> 2396 {routingTargets.length === 0 && ( 2397 <p className="text-[11px] text-text-3/70 mt-2">No route pool yet. Add one if this agent should switch between cheaper, stronger, or gateway-specific models.</p> 2398 )} 2399 </div> 2400 <div> 2401 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Filesystem Access</label> 2402 <select 2403 value={filesystemScope} 2404 onChange={(e) => setFilesystemScope(e.target.value as 'workspace' | 'machine')} 2405 className="w-full h-10 px-3 rounded-[10px] bg-white/[0.04] border border-white/[0.06] text-[14px] text-text-2" 2406 > 2407 <option value="workspace">Workspace only</option> 2408 <option value="machine">Full machine</option> 2409 </select> 2410 {filesystemScope === 'machine' && ( 2411 <p className="mt-2 text-[12px] text-amber-400/80">Agent can access any file your user account can reach. Sensitive paths (.ssh, .env, .gnupg) are blocked by default.</p> 2412 )} 2413 </div> 2414 </SectionCard> 2415 2416 <SectionCard 2417 title="Safety & Limits" 2418 description="Enable safeguards, recovery, and spend limits without crowding the main setup flow." 2419 className="mb-6 border-white/[0.05] bg-white/[0.01]" 2420 > 2421 <div className="space-y-3 mb-6"> 2422 <label className="flex items-center gap-3 cursor-pointer"> 2423 <div onClick={() => setDisabled((current) => !current)} className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${disabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}> 2424 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${disabled ? 'left-[22px]' : 'left-0.5'}`} /> 2425 </div> 2426 <span className="text-[13px] text-text-2">Disable this agent</span> 2427 </label> 2428 <label className="flex items-center gap-3 cursor-pointer"> 2429 <div onClick={() => setAutoRecovery((current) => !current)} className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${autoRecovery ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}> 2430 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${autoRecovery ? 'left-[22px]' : 'left-0.5'}`} /> 2431 </div> 2432 <span className="text-[13px] text-text-2">Guardian auto-recovery</span> 2433 </label> 2434 </div> 2435 2436 <div className="mb-4"> 2437 <label className="flex items-center gap-3 cursor-pointer"> 2438 <div onClick={() => setBudgetEnabled((current) => !current)} className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${budgetEnabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}> 2439 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${budgetEnabled ? 'left-[22px]' : 'left-0.5'}`} /> 2440 </div> 2441 <span className="text-[13px] text-text-2">Spend limits</span> 2442 </label> 2443 </div> 2444 {budgetEnabled && ( 2445 <div className="grid grid-cols-1 md:grid-cols-4 gap-3"> 2446 <input type="number" min={0} step="0.01" value={hourlyBudget} onChange={(e) => setHourlyBudget(e.target.value)} placeholder="Hourly" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2447 <input type="number" min={0} step="0.01" value={dailyBudget} onChange={(e) => setDailyBudget(e.target.value)} placeholder="Daily" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2448 <input type="number" min={0} step="0.01" value={monthlyBudget} onChange={(e) => setMonthlyBudget(e.target.value)} placeholder="Monthly" className={inputClass} style={{ fontFamily: 'inherit' }} /> 2449 <select value={budgetAction} onChange={(e) => setBudgetAction(e.target.value as typeof budgetAction)} className={inputClass} style={{ fontFamily: 'inherit' }}> 2450 <option value="warn">Warn</option> 2451 <option value="block">Block</option> 2452 </select> 2453 </div> 2454 )} 2455 </SectionCard> 2456 </>)} 2457 2458 <SectionCard 2459 title="Tools & Skills" 2460 description="Enable tool families, pin preferred skills, and connect MCP tools for this agent." 2461 className="mb-6 border-white/[0.05] bg-white/[0.01]" 2462 > 2463 {/* Tools — hidden for providers that manage capabilities outside LangGraph */} 2464 {!hasNativeCapabilities && ( 2465 <div className="mb-8"> 2466 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Tools</label> 2467 <p className="text-[12px] text-text-3/60 mb-3">Enable built-in tool families for this agent.</p> 2468 <div className="space-y-3"> 2469 {AVAILABLE_TOOLS 2470 .map((t) => { 2471 const extensionDisabled = !!t.extensionId && !!enabledExtensionIds && !enabledExtensionIds.has(t.extensionId) 2472 return ( 2473 <label key={t.id} className={`flex items-center gap-3 ${extensionDisabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`} title={extensionDisabled ? 'Enable in Extensions page' : undefined}> 2474 <div 2475 onClick={() => !extensionDisabled && setTools((prev) => prev.includes(t.id) ? prev.filter((x) => x !== t.id) : [...prev, t.id])} 2476 className={`w-11 h-6 rounded-full transition-all duration-200 relative shrink-0 2477 ${extensionDisabled ? 'bg-white/[0.04] cursor-not-allowed' : tools.includes(t.id) ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.08] cursor-pointer'}`} 2478 > 2479 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 2480 ${tools.includes(t.id) && !extensionDisabled ? 'left-[22px]' : 'left-0.5'}`} /> 2481 </div> 2482 <span className={`font-display text-[14px] font-600 ${extensionDisabled ? 'text-text-3/40' : 'text-text-2'}`}>{t.label}</span> 2483 <span className={`text-[12px] ${extensionDisabled ? 'text-text-3/30' : 'text-text-3'}`}> 2484 {extensionDisabled ? 'Enable in Extensions page' : t.description} 2485 </span> 2486 </label> 2487 ) 2488 })} 2489 </div> 2490 </div> 2491 )} 2492 2493 {/* Filesystem Access */} 2494 <div className="mb-8"> 2495 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Filesystem Access</label> 2496 <select 2497 value={filesystemScope} 2498 onChange={(e) => setFilesystemScope(e.target.value as 'workspace' | 'machine')} 2499 className="w-full h-10 px-3 rounded-[10px] bg-white/[0.04] border border-white/[0.06] text-[14px] text-text-2" 2500 > 2501 <option value="workspace">Workspace only</option> 2502 <option value="machine">Full machine</option> 2503 </select> 2504 {filesystemScope === 'machine' && ( 2505 <p className="mt-2 text-[12px] text-amber-400/80">Agent can access any file your user account can reach. Sensitive paths (.ssh, .env, .gnupg) are blocked by default.</p> 2506 )} 2507 </div> 2508 2509 {/* Platform — hidden for providers that manage capabilities outside LangGraph */} 2510 {!hasNativeCapabilities && ( 2511 <div className="mb-8"> 2512 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Platform Tools</label> 2513 <p className="text-[12px] text-text-3/60 mb-3">Allow this agent to manage platform resources directly.</p> 2514 <div className="space-y-3"> 2515 {PLATFORM_TOOLS 2516 .map((t) => { 2517 const extensionDisabled = !!t.extensionId && !!enabledExtensionIds && !enabledExtensionIds.has(t.extensionId) 2518 return ( 2519 <label key={t.id} className={`flex items-center gap-3 ${extensionDisabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`} title={extensionDisabled ? 'Enable in Extensions page' : undefined}> 2520 <div 2521 onClick={() => !extensionDisabled && setTools((prev) => prev.includes(t.id) ? prev.filter((x) => x !== t.id) : [...prev, t.id])} 2522 className={`w-11 h-6 rounded-full transition-all duration-200 relative shrink-0 2523 ${extensionDisabled ? 'bg-white/[0.04] cursor-not-allowed' : tools.includes(t.id) ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.08] cursor-pointer'}`} 2524 > 2525 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 2526 ${tools.includes(t.id) && !extensionDisabled ? 'left-[22px]' : 'left-0.5'}`} /> 2527 </div> 2528 <span className={`font-display text-[14px] font-600 ${extensionDisabled ? 'text-text-3/40' : 'text-text-2'}`}>{t.label}</span> 2529 <span className={`text-[12px] ${extensionDisabled ? 'text-text-3/30' : 'text-text-3'}`}> 2530 {extensionDisabled ? 'Enable in Extensions page' : t.description} 2531 </span> 2532 </label> 2533 ) 2534 })} 2535 </div> 2536 </div> 2537 )} 2538 2539 {/* Native capability provider note — not shown for OpenClaw (covered in connection status) */} 2540 {hasNativeCapabilities && !openclawEnabled && ( 2541 <div className="mb-8 p-4 rounded-[14px] bg-white/[0.02] border border-white/[0.06]"> 2542 <p className="text-[13px] text-text-3"> 2543 {provider === 'claude-cli' 2544 ? 'Claude CLI uses its own built-in capabilities — no additional local tool/platform configuration is needed.' 2545 : provider === 'codex-cli' 2546 ? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.). Skills and MCP servers assigned below will be injected at runtime.' 2547 : provider === 'opencode-cli' 2548 ? 'OpenCode CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration is needed.' 2549 : provider === 'gemini-cli' 2550 ? 'Gemini CLI uses its own built-in tools and runtime — SwarmClaw does not inject local platform tools for it.' 2551 : provider === 'copilot-cli' 2552 ? 'GitHub Copilot CLI uses its own built-in tools and runtime. Skills and MCP servers assigned below will be injected at runtime.' 2553 : provider === 'droid-cli' 2554 ? 'Factory Droid CLI uses its own built-in tools and autonomy controls — SwarmClaw does not inject local platform tools for it.' 2555 : provider === 'cursor-cli' 2556 ? 'Cursor Agent CLI runs with its own native tool/runtime layer — SwarmClaw sends prompts directly without injecting local platform tools.' 2557 : provider === 'qwen-code-cli' 2558 ? 'Qwen Code CLI uses its own native tools and runtime — SwarmClaw does not inject local platform tools for it.' 2559 : provider === 'goose' 2560 ? 'Goose manages its own runtime, tools, and extensions — SwarmClaw sends prompts directly instead of injecting local platform tools.' 2561 : provider === 'hermes' 2562 ? 'Hermes Agent runs behind its own API server and tool runtime. SwarmClaw sends prompts to Hermes directly instead of injecting local platform tools.' 2563 : 'This provider manages its own native capabilities and runtime.'} 2564 </p> 2565 </div> 2566 )} 2567 2568 {/* Skills — discovered from ~/.claude/skills/ */} 2569 {provider === 'claude-cli' && ( 2570 <div className="mb-8"> 2571 <div className="flex items-center justify-between mb-2"> 2572 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]"> 2573 Pinned Claude Skills <span className="normal-case tracking-normal font-normal text-text-3">(from ~/.claude/skills/)</span> 2574 </label> 2575 <button 2576 onClick={loadClaudeSkills} 2577 disabled={claudeSkillsLoading} 2578 className="text-[11px] text-text-3 hover:text-accent-bright transition-colors cursor-pointer bg-transparent border-none flex items-center gap-1" 2579 style={{ fontFamily: 'inherit' }} 2580 title="Refresh skills from ~/.claude/skills/" 2581 > 2582 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" 2583 className={claudeSkillsLoading ? 'animate-spin' : ''}> 2584 <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" /> 2585 </svg> 2586 Refresh 2587 </button> 2588 </div> 2589 <p className="text-[12px] text-text-3/60 mb-3">Optional preference list. Pinned Claude skills are called out explicitly when this agent is delegated work.</p> 2590 {claudeSkills.length > 0 ? ( 2591 <div className="flex flex-wrap gap-2"> 2592 {claudeSkills.map((s) => { 2593 const active = skills.includes(s.id) 2594 return ( 2595 <button 2596 key={s.id} 2597 onClick={() => setSkills((prev) => active ? prev.filter((x) => x !== s.id) : [...prev, s.id])} 2598 className={`px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border 2599 ${active 2600 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 2601 : 'bg-surface border-white/[0.06] text-text-3 hover:text-text-2'}`} 2602 style={{ fontFamily: 'inherit' }} 2603 title={s.description} 2604 > 2605 {s.name} 2606 </button> 2607 ) 2608 })} 2609 </div> 2610 ) : ( 2611 <p className="text-[12px] text-text-3/70">No skills found in ~/.claude/skills/</p> 2612 )} 2613 </div> 2614 )} 2615 2616 {/* Dynamic Skills from Skills Manager */} 2617 {Object.keys(dynamicSkills).length > 0 && ( 2618 <div className="mb-8"> 2619 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2620 Pinned Skills <span className="normal-case tracking-normal font-normal text-text-3">(from Skills manager)</span> 2621 </label> 2622 <p className="text-[12px] text-text-3/60 mb-3">All ready local skills are discoverable by default. Pin skills here only when they should stay in this agent's prompt as always-on guidance.</p> 2623 <div className="flex flex-wrap gap-2"> 2624 {Object.values(dynamicSkills).map((s) => { 2625 const active = skillIds.includes(s.id) 2626 return ( 2627 <button 2628 key={s.id} 2629 onClick={() => setSkillIds((prev) => active ? prev.filter((x) => x !== s.id) : [...prev, s.id])} 2630 className={`px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border 2631 ${active 2632 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 2633 : 'bg-surface border-white/[0.06] text-text-3 hover:text-text-2'}`} 2634 style={{ fontFamily: 'inherit' }} 2635 title={s.description || s.filename} 2636 > 2637 {s.name} 2638 </button> 2639 ) 2640 })} 2641 </div> 2642 </div> 2643 )} 2644 2645 {/* MCP Servers */} 2646 {Object.keys(mcpServers).length > 0 && ( 2647 <div className="mb-8"> 2648 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2649 MCP Servers 2650 </label> 2651 <p className="text-[12px] text-text-3/60 mb-3">Connect external tool servers to this agent via MCP.</p> 2652 <div className="flex flex-wrap gap-2"> 2653 {Object.values(mcpServers).map((s) => { 2654 const active = mcpServerIds.includes(s.id) 2655 return ( 2656 <button 2657 key={s.id} 2658 onClick={() => setMcpServerIds((prev) => active ? prev.filter((x) => x !== s.id) : [...prev, s.id])} 2659 className={`px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border 2660 ${active 2661 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 2662 : 'bg-surface border-white/[0.06] text-text-3 hover:text-text-2'}`} 2663 style={{ fontFamily: 'inherit' }} 2664 title={`${s.transport} — ${s.command || s.url || ''}`} 2665 > 2666 {s.name} 2667 </button> 2668 ) 2669 })} 2670 </div> 2671 </div> 2672 )} 2673 2674 {/* MCP Tools — per-tool enable/disable toggles */} 2675 {mcpServerIds.length > 0 && Object.keys(mcpTools).length > 0 && ( 2676 <div className="mb-8"> 2677 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2"> 2678 MCP Tools 2679 </label> 2680 <p className="text-[12px] text-text-3/60 mb-3"> 2681 Toggle individual tools from connected MCP servers.{mcpToolsLoading ? ' Loading…' : ''} 2682 </p> 2683 <div className="space-y-4"> 2684 {mcpServerIds.map((serverId) => { 2685 const server = mcpServers[serverId] 2686 const serverTools = mcpTools[serverId] 2687 if (!server || !serverTools?.length) return null 2688 const safeName = server.name.replace(/[^a-zA-Z0-9_]/g, '_') 2689 return ( 2690 <div key={serverId}> 2691 <p className="text-[12px] font-600 text-text-3 mb-2">{server.name}</p> 2692 <div className="space-y-3"> 2693 {serverTools.map((t) => { 2694 const fullName = `mcp_${safeName}_${t.name}` 2695 const enabled = !mcpDisabledTools.includes(fullName) 2696 return ( 2697 <label key={fullName} className="flex items-center gap-3 cursor-pointer"> 2698 <div 2699 onClick={() => setMcpDisabledTools((prev) => 2700 enabled ? [...prev, fullName] : prev.filter((x) => x !== fullName) 2701 )} 2702 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 2703 ${enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 2704 > 2705 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 2706 ${enabled ? 'left-[22px]' : 'left-0.5'}`} /> 2707 </div> 2708 <span className="font-display text-[14px] font-600 text-text-2">{t.name}</span> 2709 <span className="text-[12px] text-text-3 truncate">{t.description}</span> 2710 </label> 2711 ) 2712 })} 2713 </div> 2714 </div> 2715 ) 2716 })} 2717 </div> 2718 </div> 2719 )} 2720 2721 <div className="mb-2"> 2722 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Capabilities</label> 2723 <p className="text-[12px] text-text-3/60 mb-3">Optional tags that describe what this agent is especially good at.</p> 2724 {capabilities.length > 0 && ( 2725 <div className="mb-3 flex flex-wrap gap-2"> 2726 {capabilities.map((capability) => ( 2727 <span key={capability} className="inline-flex items-center gap-1.5 rounded-[8px] border border-accent-bright/20 bg-accent-soft/20 px-3 py-1 text-[12px] text-accent-bright"> 2728 {capability} 2729 <button 2730 type="button" 2731 onClick={() => setCapabilities((current) => current.filter((entry) => entry !== capability))} 2732 className="bg-transparent border-none text-accent-bright/70 hover:text-accent-bright cursor-pointer" 2733 > 2734 × 2735 </button> 2736 </span> 2737 ))} 2738 </div> 2739 )} 2740 <div className="flex gap-2"> 2741 <input 2742 type="text" 2743 value={capInput} 2744 onChange={(e) => setCapInput(e.target.value)} 2745 onKeyDown={(e) => { 2746 if (e.key !== 'Enter') return 2747 e.preventDefault() 2748 const next = capInput.trim() 2749 if (!next || capabilities.includes(next)) return 2750 setCapabilities((current) => [...current, next]) 2751 setCapInput('') 2752 }} 2753 placeholder="Add a capability tag" 2754 className={inputClass} 2755 style={{ fontFamily: 'inherit' }} 2756 /> 2757 <button 2758 type="button" 2759 onClick={() => { 2760 const next = capInput.trim() 2761 if (!next || capabilities.includes(next)) return 2762 setCapabilities((current) => [...current, next]) 2763 setCapInput('') 2764 }} 2765 className="shrink-0 px-3 py-2.5 rounded-[10px] bg-accent-soft/50 text-accent-bright text-[12px] font-700 hover:bg-accent-soft transition-colors cursor-pointer border border-accent-bright/20" 2766 > 2767 Add 2768 </button> 2769 </div> 2770 </div> 2771 </SectionCard> 2772 2773 <SectionCard 2774 title="Utilities" 2775 description="Import and export agents." 2776 className="mb-0 border-white/[0.05] bg-white/[0.01]" 2777 > 2778 <div className="flex flex-wrap gap-3"> 2779 {editing ? ( 2780 <button 2781 type="button" 2782 onClick={handleExport} 2783 className="px-4 py-2.5 rounded-[10px] border border-white/[0.08] bg-transparent text-text-2 text-[12px] font-600 cursor-pointer hover:bg-white/[0.04] transition-all" 2784 style={{ fontFamily: 'inherit' }} 2785 > 2786 Export agent 2787 </button> 2788 ) : ( 2789 <button 2790 type="button" 2791 onClick={() => importFileRef.current?.click()} 2792 className="px-4 py-2.5 rounded-[10px] border border-white/[0.08] bg-transparent text-text-2 text-[12px] font-600 cursor-pointer hover:bg-white/[0.04] transition-all" 2793 style={{ fontFamily: 'inherit' }} 2794 > 2795 Import agent 2796 </button> 2797 )} 2798 </div> 2799 </SectionCard> 2800 </AdvancedSettingsSection> 2801 )} 2802 2803 {/* Provider key warning */} 2804 {providerNeedsKey && ( 2805 <div className="mb-4 p-3 rounded-[12px] bg-amber-500/[0.08] border border-amber-500/20"> 2806 <p className="text-[13px] text-amber-400"> 2807 Add an API key for {currentProvider?.name || provider} above before creating this agent. 2808 </p> 2809 </div> 2810 )} 2811 2812 {/* Test connection result (hidden for OpenClaw — inline status block handles it) */} 2813 {!openclawEnabled && testStatus === 'fail' && ( 2814 <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20"> 2815 <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p> 2816 </div> 2817 )} 2818 {!openclawEnabled && testStatus === 'pass' && ( 2819 <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20"> 2820 <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p> 2821 </div> 2822 )} 2823 2824 {/* Import file input (hidden) */} 2825 <input ref={importFileRef} type="file" accept=".json" onChange={handleImport} className="hidden" /> 2826 2827 <div className="flex gap-3 pt-2 border-t border-white/[0.04]"> 2828 {editing && ( 2829 <button onClick={handleDelete} className="py-3.5 px-6 rounded-[14px] border border-red-500/20 bg-transparent text-red-400 text-[15px] font-600 cursor-pointer hover:bg-red-500/10 transition-all" style={{ fontFamily: 'inherit' }}> 2830 Delete 2831 </button> 2832 )} 2833 <button onClick={onClose} className="flex-1 py-3.5 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[15px] font-600 cursor-pointer hover:bg-surface-2 transition-all" style={{ fontFamily: 'inherit' }}> 2834 Cancel 2835 </button> 2836 <button 2837 onClick={handleTestAndSave} 2838 disabled={!name.trim() || providerNeedsKey || testStatus === 'testing' || saving || (!openclawEnabled && testStatus === 'pass')} 2839 className={`flex-1 py-3.5 rounded-[14px] border-none text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] disabled:opacity-60 transition-all hover:brightness-110 2840 ${testStatus === 'pass' ? 'bg-emerald-600 shadow-[0_4px_20px_rgba(16,185,129,0.25)]' : 'bg-accent-bright shadow-[0_4px_20px_rgba(99,102,241,0.25)]'}`} 2841 style={{ fontFamily: 'inherit' }} 2842 > 2843 {openclawEnabled 2844 ? (testStatus === 'testing' ? 'Connecting...' 2845 : testStatus === 'pass' ? (saving ? 'Saving...' : 'Save') 2846 : testStatus === 'fail' && testErrorCode === 'PAIRING_REQUIRED' ? 'Retry Connection' 2847 : testStatus === 'fail' ? 'Retry' 2848 : 'Connect') 2849 : (testStatus === 'testing' ? 'Testing...' : testStatus === 'pass' ? (saving ? 'Saving...' : 'Connected!') : needsTest ? 'Test & Save' : editing ? 'Save' : 'Create')} 2850 </button> 2851 </div> 2852 </BottomSheet> 2853 2854 <SoulLibraryPicker 2855 open={soulLibraryOpen} 2856 onClose={() => setSoulLibraryOpen(false)} 2857 onSelect={(s) => setSoul(s)} 2858 /> 2859 </> 2860 ) 2861 }