nyxora 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +22 -12
  3. package/SECURITY.md +25 -21
  4. package/assets/raw-diagram.png +0 -0
  5. package/assets/security-flow.png +0 -0
  6. package/bin/nyxora.mjs +236 -0
  7. package/launcher.js +8 -3
  8. package/launcher.ts +28 -1
  9. package/package.json +11 -8
  10. package/packages/core/package.json +4 -4
  11. package/packages/core/src/agent/reasoning.ts +10 -8
  12. package/packages/core/src/config/parser.ts +2 -1
  13. package/packages/core/src/gateway/cli.ts +2 -64
  14. package/packages/core/src/gateway/server.ts +89 -8
  15. package/packages/core/src/gateway/setup-cli.ts +7 -0
  16. package/packages/core/src/gateway/setup.ts +52 -28
  17. package/packages/core/src/gateway/telegram.ts +147 -89
  18. package/packages/core/src/memory/logger.ts +83 -20
  19. package/packages/core/src/system/pluginManager.ts +48 -34
  20. package/packages/core/src/utils/state.ts +15 -2
  21. package/packages/core/src/web3/config.ts +18 -3
  22. package/packages/core/src/web3/skills/marketAnalysis.ts +43 -17
  23. package/packages/core/src/web3/skills/swapToken.ts +9 -1
  24. package/packages/dashboard/dist/assets/index-CfIids2e.js +170 -0
  25. package/packages/dashboard/dist/assets/index-POJM-7Fd.css +1 -0
  26. package/packages/dashboard/dist/favicon.svg +1 -1
  27. package/packages/dashboard/dist/index.html +2 -2
  28. package/packages/dashboard/package.json +7 -7
  29. package/packages/dashboard/public/favicon.svg +1 -1
  30. package/packages/dashboard/src/App.tsx +224 -167
  31. package/packages/dashboard/src/Settings.tsx +55 -0
  32. package/packages/dashboard/src/Skills.tsx +8 -1
  33. package/packages/dashboard/src/index.css +146 -35
  34. package/packages/policy/package.json +1 -1
  35. package/packages/policy/src/server.ts +21 -28
  36. package/packages/signer/package.json +1 -1
  37. package/packages/signer/src/server.ts +40 -13
  38. package/test-db.ts +3 -0
  39. package/bin/nyxora.js +0 -13
  40. package/packages/dashboard/dist/assets/index-BK4qmIy6.js +0 -200
  41. package/packages/dashboard/dist/assets/index-C1m4ohce.css +0 -1
  42. package/packages/dashboard/package-lock.json +0 -2748
  43. package/packages/dashboard/src/Memory.tsx +0 -110
@@ -1,8 +1,7 @@
1
1
  import { apiFetch } from './utils/api';
2
2
  import { useState, useEffect, useRef } from 'react';
3
- import { Send, Bot, Activity, MessageSquare, LayoutDashboard, Settings as SettingsIcon, Compass, Database, Mic } from 'lucide-react';
3
+ import { Send, Bot, Activity, MessageSquare, LayoutDashboard, Settings as SettingsIcon, Zap, Database, Mic, Copy, Check, Plus, Trash2, Search, Edit2 } from 'lucide-react';
4
4
  import Overview from './Overview';
5
- import Memory from './Memory';
6
5
  import Settings from './Settings';
7
6
  import Skills from './Skills';
8
7
  import PendingTransactions from './PendingTransactions';
@@ -25,8 +24,13 @@ interface Config {
25
24
  }
26
25
 
27
26
  function App() {
28
- const [currentView, setCurrentView] = useState<'chat' | 'overview' | 'memory' | 'settings' | 'skills'>('chat');
27
+ const [currentView, setCurrentView] = useState<'chat' | 'overview' | 'settings' | 'skills'>('chat');
28
+ const [trendingTokens, setTrendingTokens] = useState<string[]>(['$BTC', '$ETH', '$SOL', '$SUI']);
29
29
  const [messages, setMessages] = useState<Message[]>([]);
30
+ const [chatSessions, setChatSessions] = useState<any[]>([]);
31
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
32
+ const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
33
+ const [editSessionTitle, setEditSessionTitle] = useState<string>('');
30
34
  const [input, setInput] = useState('');
31
35
  const [isLoading, setIsLoading] = useState(false);
32
36
  const [isListening, setIsListening] = useState(false);
@@ -35,52 +39,14 @@ function App() {
35
39
  const [isSpeaking, setIsSpeaking] = useState(false);
36
40
  const [config, setConfig] = useState<Config | null>(null);
37
41
  const [chatWidth, setChatWidth] = useState(70);
42
+ const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
38
43
  const messagesEndRef = useRef<HTMLDivElement>(null);
39
44
  const recognitionRef = useRef<any>(null);
40
- const workspaceRef = useRef<HTMLDivElement>(null);
41
- const isDragging = useRef(false);
42
45
 
43
46
  useEffect(() => {
44
- const handleMouseMove = (e: MouseEvent) => {
45
- if (!isDragging.current || !workspaceRef.current) return;
46
- const rect = workspaceRef.current.getBoundingClientRect();
47
- const newWidth = ((e.clientX - rect.left) / rect.width) * 100;
48
- if (newWidth > 20 && newWidth < 80) {
49
- setChatWidth(newWidth);
50
- }
51
- };
52
-
53
- const handleMouseUp = () => {
54
- isDragging.current = false;
55
- document.removeEventListener('mousemove', handleMouseMove);
56
- document.removeEventListener('mouseup', handleMouseUp);
57
- };
58
-
59
- // Attach them to document only when dragging is active
60
- // We will attach them in handleMouseDown
61
- }, []);
62
-
63
- const handleMouseDown = () => {
64
- isDragging.current = true;
65
-
66
- const handleMouseMove = (e: MouseEvent) => {
67
- if (!isDragging.current || !workspaceRef.current) return;
68
- const rect = workspaceRef.current.getBoundingClientRect();
69
- const newWidth = ((e.clientX - rect.left) / rect.width) * 100;
70
- if (newWidth > 25 && newWidth < 75) {
71
- setChatWidth(newWidth);
72
- }
73
- };
74
-
75
- const handleMouseUp = () => {
76
- isDragging.current = false;
77
- document.removeEventListener('mousemove', handleMouseMove);
78
- document.removeEventListener('mouseup', handleMouseUp);
79
- };
80
-
81
- document.addEventListener('mousemove', handleMouseMove);
82
- document.addEventListener('mouseup', handleMouseUp);
83
- };
47
+ // Scroll to bottom on new message
48
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
49
+ }, [messages, isLoading]);
84
50
 
85
51
  useEffect(() => {
86
52
  // Initialize Speech Recognition
@@ -151,22 +117,86 @@ function App() {
151
117
  };
152
118
 
153
119
  const fetchHistory = async () => {
120
+ if (!activeSessionId) {
121
+ setMessages([]);
122
+ return;
123
+ }
154
124
  try {
155
- const res = await apiFetch('http://localhost:3000/api/history');
125
+ const url = `http://localhost:3000/api/history?session_id=${activeSessionId}`;
126
+ const res = await apiFetch(url);
156
127
  if (res.ok) {
157
128
  const data = await res.json();
158
- setMessages(prev => {
159
- if (prev.length !== data.length || (prev.length > 0 && data.length > 0 && prev[prev.length - 1].content !== data[data.length - 1].content)) {
160
- return data;
161
- }
162
- return prev;
163
- });
129
+ setMessages(data);
164
130
  }
165
131
  } catch (err) {
166
132
  console.warn('Backend not ready, retrying history fetch in 2s...');
167
133
  }
168
134
  };
169
135
 
136
+ const fetchSessions = async () => {
137
+ try {
138
+ const res = await apiFetch('http://localhost:3000/api/sessions');
139
+ if (res.ok) {
140
+ const data = await res.json();
141
+ setChatSessions(data);
142
+ // On first load, if no active session, auto-select the most recent one
143
+ if (data.length > 0 && !activeSessionId) {
144
+ setActiveSessionId(data[0].id);
145
+ }
146
+ }
147
+ } catch (err) {}
148
+ };
149
+
150
+ const fetchTrendingTokens = async () => {
151
+ try {
152
+ const res = await apiFetch('http://localhost:3000/api/trending');
153
+ if (res.ok) {
154
+ setTrendingTokens(await res.json());
155
+ }
156
+ } catch (err) {}
157
+ };
158
+
159
+ const createNewSession = async () => {
160
+ try {
161
+ const res = await apiFetch('http://localhost:3000/api/sessions', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ title: 'New Chat' })
165
+ });
166
+ if (res.ok) {
167
+ const { id } = await res.json();
168
+ setActiveSessionId(id);
169
+ setMessages([]);
170
+ await fetchSessions();
171
+ setCurrentView('chat');
172
+ }
173
+ } catch (err) {}
174
+ };
175
+
176
+ const renameSession = async (id: string, newTitle: string) => {
177
+ try {
178
+ await apiFetch(`http://localhost:3000/api/sessions/${id}`, {
179
+ method: 'PUT',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({ title: newTitle })
182
+ });
183
+ setEditingSessionId(null);
184
+ await fetchSessions();
185
+ } catch (err) {}
186
+ };
187
+
188
+ const deleteSession = async (id: string, e: React.MouseEvent) => {
189
+ e.stopPropagation();
190
+ try {
191
+ await apiFetch(`http://localhost:3000/api/sessions/${id}`, { method: 'DELETE' });
192
+ if (activeSessionId === id) {
193
+ setActiveSessionId(null);
194
+ setMessages([]);
195
+ }
196
+ await fetchSessions();
197
+ } catch (err) {}
198
+ };
199
+
170
200
  const fetchConfig = async () => {
171
201
  try {
172
202
  const res = await apiFetch('http://localhost:3000/api/config');
@@ -198,9 +228,15 @@ function App() {
198
228
  useEffect(() => {
199
229
  fetchHistory();
200
230
  fetchConfig();
201
- const interval = setInterval(fetchHistory, 2000);
231
+ fetchSessions();
232
+ fetchTrendingTokens();
233
+ const interval = setInterval(() => {
234
+ fetchHistory();
235
+ fetchSessions();
236
+ fetchTrendingTokens();
237
+ }, 2000);
202
238
  return () => clearInterval(interval);
203
- }, []);
239
+ }, [activeSessionId]);
204
240
 
205
241
  useEffect(() => {
206
242
  // Adding a slight timeout to ensure DOM is fully rendered before scrolling
@@ -217,17 +253,45 @@ function App() {
217
253
  setInput('');
218
254
  setIsLoading(true);
219
255
 
256
+ let currentSessionId = activeSessionId;
257
+
258
+ // Auto-create session if null
259
+ if (!currentSessionId) {
260
+ try {
261
+ const title = userMsg.length > 25 ? userMsg.substring(0, 25) + '...' : userMsg;
262
+ const res = await apiFetch('http://localhost:3000/api/sessions', {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({ title })
266
+ });
267
+ if (res.ok) {
268
+ const { id } = await res.json();
269
+ currentSessionId = id;
270
+ setActiveSessionId(id);
271
+ await fetchSessions();
272
+ }
273
+ } catch (err) {
274
+ console.error("Failed to auto-create session", err);
275
+ }
276
+ }
277
+
220
278
  setMessages(prev => [...prev, { role: 'user', content: userMsg }]);
221
279
 
222
280
  try {
223
281
  const res = await apiFetch('http://localhost:3000/api/chat', {
224
282
  method: 'POST',
225
283
  headers: { 'Content-Type': 'application/json' },
226
- body: JSON.stringify({ message: userMsg }),
284
+ body: JSON.stringify({ message: userMsg, session_id: currentSessionId }),
227
285
  });
228
286
  const data = await res.json();
229
287
  await fetchHistory();
230
288
 
289
+ // Auto-rename on first prompt
290
+ if (messages.length === 0 && currentSessionId) {
291
+ const autoTitle = userMsg.length > 25 ? userMsg.substring(0, 25) + '...' : userMsg;
292
+ renameSession(currentSessionId, autoTitle);
293
+ }
294
+
231
295
  // Trigger TTS if in voice mode
232
296
  if (isVoiceModeRef.current && data.response) {
233
297
  speak(data.response);
@@ -269,7 +333,7 @@ function App() {
269
333
  <aside className="sidebar">
270
334
  <div className="agent-identity-card">
271
335
  <div className="agent-avatar">
272
- <Bot size={28} color="#3b82f6" />
336
+ <Bot size={28} color="#88c0d0" />
273
337
  </div>
274
338
  <div className="agent-info">
275
339
  <div className="agent-name">Nyxora AI</div>
@@ -280,47 +344,63 @@ function App() {
280
344
  </div>
281
345
 
282
346
  <div className="sidebar-scroll-area">
283
- <div className="sidebar-section">WORKSPACE</div>
284
- <nav className="sidebar-nav">
347
+ <nav className="sidebar-nav" style={{ paddingTop: '16px' }}>
285
348
  <div
286
- className={`nav-item ${currentView === 'chat' ? 'active' : ''}`}
287
- onClick={() => setCurrentView('chat')}
349
+ className="nav-item"
350
+ onClick={createNewSession}
288
351
  >
289
- <MessageSquare size={18} className="nav-icon" /> Chat
352
+ <Plus size={15} className="nav-icon" /> New Chat
290
353
  </div>
291
354
  <div
292
355
  className={`nav-item ${currentView === 'overview' ? 'active' : ''}`}
293
356
  onClick={() => setCurrentView('overview')}
294
357
  >
295
- <LayoutDashboard size={18} className="nav-icon" /> Overview
358
+ <LayoutDashboard size={15} className="nav-icon" /> Overview
296
359
  </div>
297
- </nav>
298
-
299
- <div className="sidebar-section">KNOWLEDGE</div>
300
- <nav className="sidebar-nav">
301
360
  <div
302
361
  className={`nav-item ${currentView === 'skills' ? 'active' : ''}`}
303
362
  onClick={() => setCurrentView('skills')}
304
363
  >
305
- <Compass size={18} className="nav-icon" /> Web3 Skills
306
- </div>
307
- <div
308
- className={`nav-item ${currentView === 'memory' ? 'active' : ''}`}
309
- onClick={() => setCurrentView('memory')}
310
- >
311
- <Database size={18} className="nav-icon" /> Memory
364
+ <Zap size={15} className="nav-icon" /> Web3 Skills
312
365
  </div>
313
- </nav>
314
-
315
- <div className="sidebar-section">SYSTEM</div>
316
- <nav className="sidebar-nav">
317
366
  <div
318
367
  className={`nav-item ${currentView === 'settings' ? 'active' : ''}`}
319
368
  onClick={() => setCurrentView('settings')}
320
369
  >
321
- <SettingsIcon size={18} className="nav-icon" /> Settings
370
+ <SettingsIcon size={15} className="nav-icon" /> Settings
322
371
  </div>
323
372
  </nav>
373
+
374
+ <div className="sidebar-section">
375
+ <span>Recent</span>
376
+ </div>
377
+ <nav className="sidebar-nav sessions-list">
378
+ {chatSessions.map((session) => (
379
+ <div
380
+ key={session.id}
381
+ className={`nav-item session-item ${activeSessionId === session.id && currentView === 'chat' ? 'active' : ''}`}
382
+ onClick={() => {
383
+ setActiveSessionId(session.id);
384
+ setCurrentView('chat');
385
+ }}
386
+ >
387
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden', flex: 1 }}>
388
+ <MessageSquare size={14} className="nav-icon" />
389
+ <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: '0.85rem' }}>
390
+ {session.title}
391
+ </span>
392
+ </div>
393
+ <div style={{ display: 'flex', gap: '4px' }}>
394
+ <button className="delete-session-btn" onClick={(e) => { e.stopPropagation(); setEditingSessionId(session.id); setEditSessionTitle(session.title); }} title="Rename Session">
395
+ <Edit2 size={12} />
396
+ </button>
397
+ <button className="delete-session-btn" onClick={(e) => deleteSession(session.id, e)} title="Delete Session">
398
+ <Trash2 size={14} />
399
+ </button>
400
+ </div>
401
+ </div>
402
+ ))}
403
+ </nav>
324
404
  </div>
325
405
  </aside>
326
406
 
@@ -350,64 +430,6 @@ function App() {
350
430
  <option value="bsc">BNB Smart Chain</option>
351
431
  <option value="arbitrum">Arbitrum</option>
352
432
  </select>
353
-
354
- <select
355
- className="config-dropdown"
356
- value={config.llm.provider}
357
- onChange={(e) => updateConfig({ ...config, llm: { ...config.llm, provider: e.target.value }})}
358
- >
359
- <option value="gemini">Google Gemini</option>
360
- <option value="openai">OpenAI</option>
361
- <option value="openrouter">OpenRouter</option>
362
- <option value="ollama">Local Ollama</option>
363
- </select>
364
-
365
- <input
366
- type="text"
367
- list="model-suggestions"
368
- className="config-dropdown"
369
- value={config.llm.model}
370
- onChange={(e) => updateConfig({ ...config, llm: { ...config.llm, model: e.target.value }})}
371
- placeholder="Enter model name..."
372
- style={{ width: '200px' }}
373
- />
374
- <datalist id="model-suggestions">
375
- {config.llm.provider === 'gemini' && (
376
- <>
377
- <option value="gemini-2.5-flash" />
378
- <option value="gemini-2.5-pro" />
379
- <option value="gemini-1.5-flash" />
380
- <option value="gemini-1.5-pro" />
381
- </>
382
- )}
383
- {config.llm.provider === 'openai' && (
384
- <>
385
- <option value="gpt-4o" />
386
- <option value="gpt-4o-mini" />
387
- <option value="gpt-4-turbo" />
388
- <option value="o1-mini" />
389
- </>
390
- )}
391
- {config.llm.provider === 'openrouter' && (
392
- <>
393
- <option value="anthropic/claude-3.5-sonnet" />
394
- <option value="anthropic/claude-3-opus" />
395
- <option value="meta-llama/llama-3.1-70b-instruct" />
396
- <option value="google/gemini-1.5-pro" />
397
- <option value="x-ai/grok-2" />
398
- <option value="mistralai/mixtral-8x7b-instruct" />
399
- <option value="deepseek/deepseek-coder" />
400
- </>
401
- )}
402
- {config.llm.provider === 'ollama' && (
403
- <>
404
- <option value="llama3" />
405
- <option value="llama3.1" />
406
- <option value="mistral" />
407
- <option value="qwen2" />
408
- </>
409
- )}
410
- </datalist>
411
433
  </>
412
434
  )}
413
435
  </div>
@@ -417,19 +439,26 @@ function App() {
417
439
  <Overview config={config} />
418
440
  ) : currentView === 'skills' ? (
419
441
  <Skills />
420
- ) : currentView === 'memory' ? (
421
- <Memory />
422
442
  ) : currentView === 'settings' ? (
423
443
  <Settings config={config} onConfigChange={setConfig} />
424
444
  ) : (
425
- <div className="workspace-container" ref={workspaceRef}>
426
- <div className="chat-wrapper" style={{ width: `${chatWidth}%` }}>
445
+ <div className="workspace-container">
446
+ <div className="chat-wrapper" style={{ width: '100%', margin: '0 auto', maxWidth: '1000px' }}>
427
447
  <div className="chat-container">
428
448
  {messages.map((msg, idx) => {
449
+ const handleCopy = () => {
450
+ navigator.clipboard.writeText(msg.content);
451
+ setCopiedIndex(idx);
452
+ setTimeout(() => setCopiedIndex(null), 2000);
453
+ };
454
+
429
455
  if (msg.role === 'user') {
430
456
  return (
431
457
  <div key={idx} className="message-wrapper user">
432
458
  <div className="message-bubble">{msg.content}</div>
459
+ <button className="copy-btn" onClick={handleCopy} title="Copy message">
460
+ {copiedIndex === idx ? <Check size={14} color="#a3be8c" /> : <Copy size={14} />}
461
+ </button>
433
462
  </div>
434
463
  );
435
464
  }
@@ -437,6 +466,9 @@ function App() {
437
466
  return (
438
467
  <div key={idx} className="message-wrapper agent">
439
468
  <div className="message-bubble">{renderMessageContent(msg.content)}</div>
469
+ <button className="copy-btn" onClick={handleCopy} title="Copy message">
470
+ {copiedIndex === idx ? <Check size={14} color="#a3be8c" /> : <Copy size={14} />}
471
+ </button>
440
472
  </div>
441
473
  );
442
474
  }
@@ -487,39 +519,64 @@ function App() {
487
519
  <Send size={20} />
488
520
  </button>
489
521
  </form>
490
- </div>
491
- </div>
492
-
493
- <div className="resizer" onMouseDown={handleMouseDown} />
494
-
495
- <div className="canvas-panel">
496
- <div className="canvas-header">
497
- <div className="canvas-title">
498
- <Compass size={16} />
499
- LIVE CANVAS
500
- </div>
501
- <div style={{ color: '#4ade80', fontSize: '0.75rem', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '4px' }}>
502
- <span style={{ width: '8px', height: '8px', background: '#4ade80', borderRadius: '50%', display: 'inline-block' }}></span>
503
- A2UI CONNECTED
522
+ <div className="trending-tokens">
523
+ <span>Trending Tokens:</span>
524
+ {trendingTokens.map((token, idx) => (
525
+ <span
526
+ key={idx}
527
+ className="token-tag"
528
+ onClick={() => setInput(`Tolong berikan analisis pasar terbaru untuk ${token}`)}
529
+ title={`Click to analyze ${token}`}
530
+ style={{ cursor: 'pointer' }}
531
+ >
532
+ {token}
533
+ </span>
534
+ ))}
504
535
  </div>
505
536
  </div>
506
-
507
- {activeWidget ? (
508
- <div style={{ marginTop: '24px' }}>
509
- {activeWidget}
510
- </div>
511
- ) : (
512
- <div className="canvas-empty">
513
- <LayoutDashboard size={48} color="rgba(255,255,255,0.1)" />
514
- <p>Awaiting agent interaction...</p>
515
- <span style={{ fontSize: '0.8rem' }}>Ask the agent to check your balance or make a transfer.</span>
516
- </div>
517
- )}
518
- <PendingTransactions />
519
537
  </div>
520
538
  </div>
521
539
  )}
522
540
  </main>
541
+
542
+ {editingSessionId && (
543
+ <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
544
+ <div style={{ background: '#1e1e24', borderRadius: '16px', padding: '24px', width: '400px', boxShadow: '0 10px 25px rgba(0,0,0,0.5)', border: '1px solid rgba(255,255,255,0.1)' }}>
545
+ <h3 style={{ margin: '0 0 16px 0', fontSize: '1.2rem', color: '#e2e8f0', fontWeight: 500 }}>Rename this chat</h3>
546
+ <input
547
+ type="text"
548
+ value={editSessionTitle}
549
+ onChange={(e) => setEditSessionTitle(e.target.value)}
550
+ onKeyDown={(e) => {
551
+ if (e.key === 'Enter') renameSession(editingSessionId, editSessionTitle);
552
+ if (e.key === 'Escape') setEditingSessionId(null);
553
+ }}
554
+ autoFocus
555
+ style={{ width: '100%', background: 'transparent', color: '#fff', border: '1px solid #3f4451', borderRadius: '8px', padding: '14px 16px', fontSize: '0.95rem', outline: 'none', boxSizing: 'border-box' }}
556
+ onFocus={(e) => e.target.style.borderColor = '#88c0d0'}
557
+ onBlur={(e) => e.target.style.borderColor = '#3f4451'}
558
+ />
559
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '24px' }}>
560
+ <button
561
+ onClick={() => setEditingSessionId(null)}
562
+ style={{ background: 'transparent', border: 'none', color: '#a0aec0', cursor: 'pointer', padding: '10px 20px', borderRadius: '24px', fontWeight: 500, fontSize: '0.9rem' }}
563
+ onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
564
+ onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
565
+ >
566
+ Cancel
567
+ </button>
568
+ <button
569
+ onClick={() => renameSession(editingSessionId, editSessionTitle)}
570
+ style={{ background: '#88c0d0', border: 'none', color: '#13131a', cursor: 'pointer', padding: '10px 20px', borderRadius: '24px', fontWeight: 600, fontSize: '0.9rem' }}
571
+ onMouseEnter={(e) => e.currentTarget.style.opacity = '0.9'}
572
+ onMouseLeave={(e) => e.currentTarget.style.opacity = '1'}
573
+ >
574
+ Rename
575
+ </button>
576
+ </div>
577
+ </div>
578
+ </div>
579
+ )}
523
580
  </>
524
581
  );
525
582
  }
@@ -5,6 +5,7 @@ import { Save } from 'lucide-react';
5
5
  interface Config {
6
6
  agent: { name: string; default_chain: string };
7
7
  llm: { provider: string; model: string; temperature: number; api_keys?: string[] };
8
+ web3?: { rpc_urls?: Record<string, string | string[]> };
8
9
  }
9
10
 
10
11
  interface SettingsProps {
@@ -30,6 +31,9 @@ const Settings: React.FC<SettingsProps> = ({ config, onConfigChange }) => {
30
31
  api_keys: Array.isArray(config.llm?.api_keys)
31
32
  ? config.llm.api_keys
32
33
  : (config.llm?.api_keys ? [config.llm.api_keys as unknown as string] : [])
34
+ },
35
+ web3: {
36
+ rpc_urls: config.web3?.rpc_urls || {}
33
37
  }
34
38
  });
35
39
  }
@@ -50,6 +54,30 @@ const Settings: React.FC<SettingsProps> = ({ config, onConfigChange }) => {
50
54
  });
51
55
  };
52
56
 
57
+ const handleWeb3Change = (chainName: string, value: string) => {
58
+ setFormData(prev => {
59
+ if (!prev) return prev;
60
+ const urls = value.split(',').map(s => s.trim()).filter(s => s);
61
+ const newRpcUrls = { ...(prev.web3?.rpc_urls || {}) };
62
+
63
+ if (urls.length === 0) {
64
+ delete newRpcUrls[chainName];
65
+ } else if (urls.length === 1) {
66
+ newRpcUrls[chainName] = urls[0];
67
+ } else {
68
+ newRpcUrls[chainName] = urls;
69
+ }
70
+
71
+ return {
72
+ ...prev,
73
+ web3: {
74
+ ...prev.web3,
75
+ rpc_urls: newRpcUrls
76
+ }
77
+ };
78
+ });
79
+ };
80
+
53
81
  const handleAddApiKey = () => {
54
82
  setFormData(prev => {
55
83
  if (!prev) return prev;
@@ -217,6 +245,33 @@ const Settings: React.FC<SettingsProps> = ({ config, onConfigChange }) => {
217
245
  </div>
218
246
  </div>
219
247
 
248
+ <div className="panel">
249
+ <div className="panel-header">
250
+ <h3>Web3 & RPC Settings</h3>
251
+ <p style={{ fontSize: '0.85rem', color: '#64748b', marginTop: '4px' }}>
252
+ Override the default public RPCs with your own Premium endpoints (Alchemy/Infura).
253
+ Separate multiple URLs with a comma for Fallback High-Availability.
254
+ </p>
255
+ </div>
256
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginTop: '16px' }}>
257
+ {['ethereum', 'base', 'bsc', 'arbitrum', 'optimism', 'sepolia'].map(chain => {
258
+ const rpcVal = formData.web3?.rpc_urls?.[chain];
259
+ const displayVal = Array.isArray(rpcVal) ? rpcVal.join(', ') : (rpcVal || '');
260
+ return (
261
+ <div key={chain} className="form-group">
262
+ <label style={{ textTransform: 'capitalize' }}>{chain} RPC</label>
263
+ <input
264
+ type="text"
265
+ placeholder="https://..."
266
+ value={displayVal}
267
+ onChange={(e) => handleWeb3Change(chain, e.target.value)}
268
+ />
269
+ </div>
270
+ );
271
+ })}
272
+ </div>
273
+ </div>
274
+
220
275
  <div className="form-actions" style={{ justifyContent: 'flex-end', marginTop: '32px' }}>
221
276
  <button className="btn-primary" onClick={handleSave} disabled={isSaving}>
222
277
  <Save size={16} style={{ marginRight: '8px', display: 'inline' }} />
@@ -21,6 +21,13 @@ interface SkillDefinition {
21
21
  };
22
22
  }
23
23
 
24
+ const formatSkillName = (name: string) => {
25
+ return name
26
+ .split('_')
27
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
28
+ .join(' ');
29
+ };
30
+
24
31
  const Skills: React.FC = () => {
25
32
  const [skills, setSkills] = useState<SkillDefinition[]>([]);
26
33
  const [isLoading, setIsLoading] = useState(true);
@@ -55,7 +62,7 @@ const Skills: React.FC = () => {
55
62
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
56
63
  <Compass size={18} color="#3b82f6" />
57
64
  <h3 style={{ margin: 0, fontFamily: 'monospace', fontSize: '1.1rem', color: '#60a5fa' }}>
58
- {skill.function.name}
65
+ {formatSkillName(skill.function.name)}
59
66
  </h3>
60
67
  </div>
61
68
  <span className="badge" style={{ background: 'rgba(34, 197, 94, 0.2)', color: '#4ade80' }}>Active</span>