agent-files-editor.tsx
1 'use client' 2 3 import { useEffect, useState } from 'react' 4 import { api } from '@/lib/app/api-client' 5 import { errorMessage } from '@/lib/shared-utils' 6 import { PersonalityBuilder } from './personality-builder' 7 8 const FILES = ['SOUL.md', 'IDENTITY.md', 'USER.md', 'TOOLS.md', 'HEARTBEAT.md', 'MEMORY.md', 'AGENTS.md'] as const 9 const GUIDED_FILES = new Set(['SOUL.md', 'IDENTITY.md', 'USER.md']) 10 11 function makeInitialFiles(): Record<string, FileState> { 12 const initial: Record<string, FileState> = {} 13 for (const f of FILES) { 14 initial[f] = { content: '', original: '', loading: true, saving: false } 15 } 16 return initial 17 } 18 19 interface FileState { 20 content: string 21 original: string 22 loading: boolean 23 saving: boolean 24 error?: string 25 } 26 27 interface Props { 28 agentId: string 29 } 30 31 export function AgentFilesEditor({ agentId }: Props) { 32 const [activeTab, setActiveTab] = useState<string>(FILES[0]) 33 const [files, setFiles] = useState<Record<string, FileState>>(makeInitialFiles) 34 const [guidedMode, setGuidedMode] = useState(false) 35 36 // Reset to loading state when agentId changes 37 const [prevAgentId, setPrevAgentId] = useState(agentId) 38 if (agentId !== prevAgentId) { 39 setPrevAgentId(agentId) 40 setFiles(makeInitialFiles()) 41 } 42 43 useEffect(() => { 44 let cancelled = false 45 api<Record<string, { content: string; error?: string }>>('GET', `/openclaw/agent-files?agentId=${agentId}`) 46 .then((result) => { 47 if (cancelled) return 48 setFiles((prev) => { 49 const next = { ...prev } 50 for (const [name, data] of Object.entries(result)) { 51 next[name] = { 52 content: data.content, 53 original: data.content, 54 loading: false, 55 saving: false, 56 error: data.error, 57 } 58 } 59 return next 60 }) 61 }) 62 .catch((err: unknown) => { 63 if (cancelled) return 64 const message = errorMessage(err) 65 setFiles((prev) => { 66 const next = { ...prev } 67 for (const f of FILES) { 68 next[f] = { ...next[f], loading: false, error: message } 69 } 70 return next 71 }) 72 }) 73 return () => { cancelled = true } 74 }, [agentId]) 75 76 const handleContentChange = (filename: string, content: string) => { 77 setFiles((prev) => ({ 78 ...prev, 79 [filename]: { ...prev[filename], content }, 80 })) 81 } 82 83 const handleSave = async (filename: string) => { 84 const file = files[filename] 85 if (!file || file.content === file.original) return 86 87 setFiles((prev) => ({ 88 ...prev, 89 [filename]: { ...prev[filename], saving: true, error: undefined }, 90 })) 91 92 try { 93 await api('PUT', '/openclaw/agent-files', { agentId, filename, content: file.content }) 94 setFiles((prev) => ({ 95 ...prev, 96 [filename]: { ...prev[filename], saving: false, original: prev[filename].content }, 97 })) 98 } catch (err: unknown) { 99 const message = errorMessage(err) 100 setFiles((prev) => ({ 101 ...prev, 102 [filename]: { ...prev[filename], saving: false, error: message }, 103 })) 104 } 105 } 106 107 const handleGuidedSave = (content: string) => { 108 handleContentChange(activeTab, content) 109 } 110 111 const current = files[activeTab] 112 const isDirty = current && current.content !== current.original 113 const showGuided = guidedMode && GUIDED_FILES.has(activeTab) 114 115 return ( 116 <div className="flex flex-col h-full"> 117 {/* Tab bar */} 118 <div className="flex gap-0.5 px-2 pt-2 pb-1 overflow-x-auto shrink-0"> 119 {FILES.map((f) => { 120 const fileState = files[f] 121 const dirty = fileState && fileState.content !== fileState.original 122 return ( 123 <button 124 key={f} 125 onClick={() => setActiveTab(f)} 126 className={`px-2.5 py-1.5 rounded-[8px] text-[11px] font-600 cursor-pointer transition-all whitespace-nowrap 127 ${activeTab === f 128 ? 'bg-accent-soft text-accent-bright' 129 : 'bg-transparent text-text-3 hover:text-text-2'}`} 130 style={{ fontFamily: 'inherit' }} 131 > 132 {f.replace('.md', '')} 133 {dirty && <span className="ml-1 text-amber-400">*</span>} 134 </button> 135 ) 136 })} 137 </div> 138 139 {/* Guided toggle for personality files */} 140 {GUIDED_FILES.has(activeTab) && ( 141 <div className="px-3 py-1 shrink-0"> 142 <button 143 onClick={() => setGuidedMode(!guidedMode)} 144 className={`text-[10px] font-600 px-2 py-0.5 rounded-[6px] cursor-pointer transition-all border-none 145 ${guidedMode ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.04] text-text-3 hover:text-text-2'}`} 146 style={{ fontFamily: 'inherit' }} 147 > 148 {guidedMode ? 'Raw Editor' : 'Guided Editor'} 149 </button> 150 </div> 151 )} 152 153 {/* Editor area */} 154 <div className="flex-1 min-h-0 px-2 pb-2 overflow-y-auto"> 155 {current?.loading ? ( 156 <div className="flex items-center justify-center h-full text-[13px] text-text-3/50">Loading...</div> 157 ) : current?.error ? ( 158 <div className="flex items-center justify-center h-full text-[13px] text-red-400">{current.error}</div> 159 ) : showGuided ? ( 160 <div className="p-2"> 161 <PersonalityBuilder 162 agentId={agentId} 163 fileType={activeTab as 'IDENTITY.md' | 'USER.md' | 'SOUL.md'} 164 content={current?.content ?? ''} 165 onSave={handleGuidedSave} 166 /> 167 </div> 168 ) : ( 169 <textarea 170 value={current?.content ?? ''} 171 onChange={(e) => handleContentChange(activeTab, e.target.value)} 172 className="w-full h-full resize-none rounded-[10px] border border-white/[0.06] bg-black/20 px-3 py-2.5 173 text-[13px] text-text font-mono leading-relaxed outline-none 174 placeholder:text-text-3/40 focus:border-white/[0.12] transition-colors" 175 placeholder={`${activeTab} content...`} 176 style={{ fontFamily: 'ui-monospace, monospace' }} 177 /> 178 )} 179 </div> 180 181 {/* Save bar */} 182 <div className="shrink-0 px-3 pb-2 flex items-center gap-2"> 183 <button 184 onClick={() => handleSave(activeTab)} 185 disabled={!isDirty || current?.saving} 186 className="px-4 py-1.5 rounded-[8px] border-none bg-accent-bright text-white text-[12px] font-600 187 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed transition-all hover:brightness-110" 188 style={{ fontFamily: 'inherit' }} 189 > 190 {current?.saving ? 'Saving...' : 'Save'} 191 </button> 192 {isDirty && ( 193 <span className="text-[11px] text-amber-400/70">Unsaved changes</span> 194 )} 195 </div> 196 </div> 197 ) 198 }