@swarmclawai/swarmclaw 0.6.3 → 0.6.6

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 (106) hide show
  1. package/README.md +5 -3
  2. package/package.json +5 -1
  3. package/src/app/api/chatrooms/[id]/chat/route.ts +41 -2
  4. package/src/app/api/chatrooms/[id]/route.ts +15 -1
  5. package/src/app/api/chatrooms/route.ts +15 -2
  6. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  7. package/src/app/api/tasks/route.ts +24 -0
  8. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  9. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  10. package/src/app/api/wallets/[id]/route.ts +118 -0
  11. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  12. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  13. package/src/app/api/wallets/route.ts +74 -0
  14. package/src/app/globals.css +8 -0
  15. package/src/app/page.tsx +7 -3
  16. package/src/cli/index.js +15 -0
  17. package/src/cli/spec.js +14 -0
  18. package/src/components/agents/agent-avatar.tsx +15 -1
  19. package/src/components/agents/agent-card.tsx +1 -0
  20. package/src/components/agents/agent-chat-list.tsx +1 -1
  21. package/src/components/agents/agent-sheet.tsx +112 -26
  22. package/src/components/auth/access-key-gate.tsx +22 -11
  23. package/src/components/chat/chat-area.tsx +2 -2
  24. package/src/components/chat/chat-header.tsx +48 -19
  25. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  26. package/src/components/chat/delegation-banner.test.ts +27 -0
  27. package/src/components/chat/delegation-banner.tsx +109 -23
  28. package/src/components/chat/message-bubble.tsx +14 -3
  29. package/src/components/chat/message-list.tsx +5 -4
  30. package/src/components/chat/streaming-bubble.tsx +3 -2
  31. package/src/components/chat/thinking-indicator.tsx +3 -2
  32. package/src/components/chat/tool-call-bubble.test.ts +28 -0
  33. package/src/components/chat/tool-call-bubble.tsx +13 -1
  34. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  35. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  36. package/src/components/chatrooms/chatroom-input.tsx +7 -6
  37. package/src/components/chatrooms/chatroom-message.tsx +1 -1
  38. package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
  39. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  40. package/src/components/chatrooms/chatroom-view.tsx +1 -1
  41. package/src/components/connectors/connector-list.tsx +1 -1
  42. package/src/components/home/home-view.tsx +2 -1
  43. package/src/components/input/chat-input.tsx +5 -4
  44. package/src/components/knowledge/knowledge-list.tsx +1 -1
  45. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  46. package/src/components/layout/app-layout.tsx +23 -9
  47. package/src/components/logs/log-list.tsx +7 -7
  48. package/src/components/memory/memory-agent-list.tsx +1 -1
  49. package/src/components/memory/memory-browser.tsx +1 -0
  50. package/src/components/memory/memory-card.tsx +3 -2
  51. package/src/components/memory/memory-detail.tsx +3 -3
  52. package/src/components/memory/memory-sheet.tsx +2 -2
  53. package/src/components/projects/project-detail.tsx +4 -4
  54. package/src/components/secrets/secret-sheet.tsx +1 -1
  55. package/src/components/secrets/secrets-list.tsx +1 -1
  56. package/src/components/sessions/new-session-sheet.tsx +4 -3
  57. package/src/components/sessions/session-card.tsx +1 -1
  58. package/src/components/shared/agent-picker-list.tsx +1 -1
  59. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  60. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  61. package/src/components/skills/skill-list.tsx +1 -1
  62. package/src/components/skills/skill-sheet.tsx +1 -1
  63. package/src/components/tasks/task-board.tsx +3 -3
  64. package/src/components/tasks/task-sheet.tsx +21 -1
  65. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  66. package/src/components/wallets/wallet-panel.tsx +616 -0
  67. package/src/components/wallets/wallet-section.tsx +100 -0
  68. package/src/hooks/use-media-query.ts +30 -4
  69. package/src/lib/api-client.ts +6 -18
  70. package/src/lib/fetch-timeout.ts +17 -0
  71. package/src/lib/notification-sounds.ts +4 -4
  72. package/src/lib/safe-storage.ts +42 -0
  73. package/src/lib/server/agent-registry.ts +2 -2
  74. package/src/lib/server/chat-execution.ts +35 -3
  75. package/src/lib/server/chatroom-health.ts +60 -0
  76. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  77. package/src/lib/server/chatroom-helpers.ts +64 -11
  78. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  79. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  80. package/src/lib/server/connectors/manager.ts +80 -2
  81. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  82. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  83. package/src/lib/server/connectors/whatsapp.ts +8 -5
  84. package/src/lib/server/orchestrator-lg.ts +12 -2
  85. package/src/lib/server/orchestrator.ts +6 -1
  86. package/src/lib/server/queue-followups.test.ts +224 -0
  87. package/src/lib/server/queue.ts +226 -24
  88. package/src/lib/server/scheduler.ts +3 -0
  89. package/src/lib/server/session-tools/chatroom.ts +11 -2
  90. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  91. package/src/lib/server/session-tools/index.ts +6 -2
  92. package/src/lib/server/session-tools/memory.ts +1 -1
  93. package/src/lib/server/session-tools/shell.ts +1 -1
  94. package/src/lib/server/session-tools/wallet.ts +124 -0
  95. package/src/lib/server/session-tools/web-output.test.ts +29 -0
  96. package/src/lib/server/session-tools/web-output.ts +16 -0
  97. package/src/lib/server/session-tools/web.ts +7 -3
  98. package/src/lib/server/solana.ts +122 -0
  99. package/src/lib/server/storage.ts +38 -0
  100. package/src/lib/server/stream-agent-chat.ts +126 -63
  101. package/src/lib/server/task-mention.test.ts +41 -0
  102. package/src/lib/server/task-mention.ts +3 -2
  103. package/src/lib/tool-definitions.ts +1 -0
  104. package/src/lib/view-routes.ts +6 -1
  105. package/src/stores/use-app-store.ts +17 -11
  106. package/src/types/index.ts +60 -1
@@ -0,0 +1,74 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { genId } from '@/lib/id'
3
+ import { loadWallets, upsertWallet, loadAgents, saveAgents } from '@/lib/server/storage'
4
+ import { generateSolanaKeypair } from '@/lib/server/solana'
5
+ import { notify } from '@/lib/server/ws-hub'
6
+ import type { AgentWallet, WalletChain } from '@/types'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ /** Strip encryptedPrivateKey from wallet for safe API responses */
10
+ function stripPrivateKey(wallet: Record<string, unknown>): Record<string, unknown> {
11
+ return Object.fromEntries(Object.entries(wallet).filter(([k]) => k !== 'encryptedPrivateKey'))
12
+ }
13
+
14
+ export async function GET() {
15
+ const wallets = loadWallets() as Record<string, AgentWallet>
16
+ const safe = Object.fromEntries(
17
+ Object.entries(wallets).map(([id, w]) => [id, stripPrivateKey(w as unknown as Record<string, unknown>)]),
18
+ )
19
+ return NextResponse.json(safe)
20
+ }
21
+
22
+ export async function POST(req: Request) {
23
+ const body = await req.json()
24
+ const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : ''
25
+ if (!agentId) {
26
+ return NextResponse.json({ error: 'agentId is required' }, { status: 400 })
27
+ }
28
+
29
+ const agents = loadAgents()
30
+ if (!agents[agentId]) {
31
+ return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
32
+ }
33
+
34
+ // Check agent doesn't already have a wallet
35
+ const existing = loadWallets() as Record<string, AgentWallet>
36
+ const hasWallet = Object.values(existing).some((w) => w.agentId === agentId)
37
+ if (hasWallet) {
38
+ return NextResponse.json({ error: 'Agent already has a wallet' }, { status: 409 })
39
+ }
40
+
41
+ const chain: WalletChain = body.chain === 'solana' ? 'solana' : 'solana' // extensible later
42
+ const { publicKey, encryptedPrivateKey } = generateSolanaKeypair()
43
+
44
+ const id = genId()
45
+ const now = Date.now()
46
+
47
+ const wallet: AgentWallet = {
48
+ id,
49
+ agentId,
50
+ chain,
51
+ publicKey,
52
+ encryptedPrivateKey,
53
+ label: typeof body.label === 'string' ? body.label : undefined,
54
+ spendingLimitLamports: typeof body.spendingLimitLamports === 'number' ? body.spendingLimitLamports : 100_000_000,
55
+ dailyLimitLamports: typeof body.dailyLimitLamports === 'number' ? body.dailyLimitLamports : 1_000_000_000,
56
+ requireApproval: body.requireApproval !== false,
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ }
60
+
61
+ upsertWallet(id, wallet)
62
+
63
+ // Link wallet to agent
64
+ const agent = agents[agentId]
65
+ agent.walletId = id
66
+ agent.updatedAt = now
67
+ agents[agentId] = agent
68
+ saveAgents(agents)
69
+
70
+ notify('wallets')
71
+ notify('agents')
72
+
73
+ return NextResponse.json(stripPrivateKey(wallet as unknown as Record<string, unknown>))
74
+ }
@@ -215,6 +215,14 @@ textarea:hover::-webkit-scrollbar { width: 6px; }
215
215
  to { opacity: 1; transform: translateY(0); }
216
216
  }
217
217
  @keyframes spin { to { transform: rotate(360deg); } }
218
+ @keyframes pulse-glow {
219
+ 0%, 100% { transform: scale(1); opacity: 0.45; }
220
+ 50% { transform: scale(1.2); opacity: 0.85; }
221
+ }
222
+ @keyframes avatar-pulse {
223
+ 0%, 100% { transform: scale(1); }
224
+ 50% { transform: scale(1.1); }
225
+ }
218
226
  @keyframes slide-in-left {
219
227
  from { transform: translateX(-100%); }
220
228
  to { transform: translateX(0); }
package/src/app/page.tsx CHANGED
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { initAudioContext } from '@/lib/tts'
6
6
  import { getStoredAccessKey, clearStoredAccessKey, api } from '@/lib/api-client'
7
7
  import { connectWs, disconnectWs } from '@/lib/ws-client'
8
+ import { fetchWithTimeout } from '@/lib/fetch-timeout'
8
9
  import { useWs } from '@/hooks/use-ws'
9
10
  import { AccessKeyGate } from '@/components/auth/access-key-gate'
10
11
  import { UserPicker } from '@/components/auth/user-picker'
@@ -12,6 +13,8 @@ import { SetupWizard } from '@/components/auth/setup-wizard'
12
13
  import { AppLayout } from '@/components/layout/app-layout'
13
14
  import { useViewRouter } from '@/hooks/use-view-router'
14
15
 
16
+ const AUTH_CHECK_TIMEOUT_MS = 8_000
17
+
15
18
  function FullScreenLoader() {
16
19
  return (
17
20
  <div className="h-full flex flex-col items-center justify-center bg-bg overflow-hidden select-none">
@@ -158,11 +161,11 @@ export default function Home() {
158
161
  }
159
162
 
160
163
  try {
161
- const res = await fetch('/api/auth', {
164
+ const res = await fetchWithTimeout('/api/auth', {
162
165
  method: 'POST',
163
166
  headers: { 'Content-Type': 'application/json' },
164
167
  body: JSON.stringify({ key }),
165
- })
168
+ }, AUTH_CHECK_TIMEOUT_MS)
166
169
  if (res.ok) {
167
170
  setAuthenticated(true)
168
171
  } else {
@@ -171,8 +174,9 @@ export default function Home() {
171
174
  }
172
175
  } catch {
173
176
  setAuthenticated(true)
177
+ } finally {
178
+ setAuthChecked(true)
174
179
  }
175
- setAuthChecked(true)
176
180
  }, [])
177
181
 
178
182
  // After auth, try to restore username from server settings
package/src/cli/index.js CHANGED
@@ -485,6 +485,21 @@ const COMMAND_GROUPS = [
485
485
  }),
486
486
  ],
487
487
  },
488
+ {
489
+ name: 'wallets',
490
+ description: 'Manage agent wallets and wallet transactions',
491
+ commands: [
492
+ cmd('list', 'GET', '/wallets', 'List wallets'),
493
+ cmd('get', 'GET', '/wallets/:id', 'Get wallet by id'),
494
+ cmd('create', 'POST', '/wallets', 'Create wallet', { expectsJsonBody: true }),
495
+ cmd('update', 'PATCH', '/wallets/:id', 'Update wallet settings', { expectsJsonBody: true }),
496
+ cmd('delete', 'DELETE', '/wallets/:id', 'Delete wallet'),
497
+ cmd('send', 'POST', '/wallets/:id/send', 'Send funds from wallet', { expectsJsonBody: true }),
498
+ cmd('approve', 'POST', '/wallets/:id/approve', 'Approve or deny a pending wallet transaction', { expectsJsonBody: true }),
499
+ cmd('transactions', 'GET', '/wallets/:id/transactions', 'List wallet transactions'),
500
+ cmd('balance-history', 'GET', '/wallets/:id/balance-history', 'Get wallet balance history'),
501
+ ],
502
+ },
488
503
  {
489
504
  name: 'upload',
490
505
  description: 'Upload raw file/blob',
package/src/cli/spec.js CHANGED
@@ -351,6 +351,20 @@ const COMMAND_GROUPS = {
351
351
  metrics: { description: 'Get task board metrics (supports --query range=24h|7d|30d)', method: 'GET', path: '/tasks/metrics' },
352
352
  },
353
353
  },
354
+ wallets: {
355
+ description: 'Agent wallet operations',
356
+ commands: {
357
+ list: { description: 'List wallets', method: 'GET', path: '/wallets' },
358
+ get: { description: 'Get wallet by id', method: 'GET', path: '/wallets/:id', params: ['id'] },
359
+ create: { description: 'Create wallet', method: 'POST', path: '/wallets' },
360
+ update: { description: 'Update wallet settings', method: 'PATCH', path: '/wallets/:id', params: ['id'] },
361
+ delete: { description: 'Delete wallet', method: 'DELETE', path: '/wallets/:id', params: ['id'] },
362
+ send: { description: 'Send funds from wallet', method: 'POST', path: '/wallets/:id/send', params: ['id'] },
363
+ approve: { description: 'Approve or deny pending wallet transaction', method: 'POST', path: '/wallets/:id/approve', params: ['id'] },
364
+ transactions: { description: 'List wallet transactions', method: 'GET', path: '/wallets/:id/transactions', params: ['id'] },
365
+ 'balance-history': { description: 'Get wallet balance history', method: 'GET', path: '/wallets/:id/balance-history', params: ['id'] },
366
+ },
367
+ },
354
368
  webhooks: {
355
369
  description: 'Inbound webhook triggers',
356
370
  commands: {
@@ -13,6 +13,7 @@ function sanitizeSvg(svg: string): string {
13
13
 
14
14
  interface Props {
15
15
  seed?: string | null
16
+ avatarUrl?: string | null
16
17
  name: string
17
18
  size?: number
18
19
  className?: string
@@ -27,7 +28,7 @@ const STATUS_COLORS: Record<string, string> = {
27
28
 
28
29
  const HEART_PATH = 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
29
30
 
30
- export function AgentAvatar({ seed, name, size = 32, className = '', status, heartbeatPulse }: Props) {
31
+ export function AgentAvatar({ seed, avatarUrl, name, size = 32, className = '', status, heartbeatPulse }: Props) {
31
32
  const svgHtml = useMemo(() => {
32
33
  if (!seed) return null
33
34
  return sanitizeSvg(multiavatar(seed))
@@ -53,6 +54,19 @@ export function AgentAvatar({ seed, name, size = 32, className = '', status, hea
53
54
  </svg>
54
55
  ) : null
55
56
 
57
+ if (avatarUrl) {
58
+ return (
59
+ <div className={`relative shrink-0 ${className}`} style={{ width: size, height: size }}>
60
+ <div className="rounded-full overflow-hidden w-full h-full">
61
+ {/* eslint-disable-next-line @next/next/no-img-element */}
62
+ <img src={avatarUrl} alt={name} className="w-full h-full object-cover" draggable={false} />
63
+ </div>
64
+ {heartEl}
65
+ {dot}
66
+ </div>
67
+ )
68
+ }
69
+
56
70
  if (svgHtml) {
57
71
  return (
58
72
  <div className={`relative shrink-0 ${className}`} style={{ width: size, height: size }}>
@@ -157,6 +157,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
157
157
  <div className="flex items-center gap-2.5">
158
158
  <AgentAvatar
159
159
  seed={agent.avatarSeed}
160
+ avatarUrl={agent.avatarUrl}
160
161
  name={agent.name}
161
162
  size={28}
162
163
  status={isRunning ? 'busy' : isOnline ? 'online' : undefined}
@@ -223,7 +223,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
223
223
  >
224
224
  <div className="flex items-center gap-2.5">
225
225
  <div className="relative shrink-0">
226
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={36} />
226
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={36} />
227
227
  <div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-bg ${
228
228
  isWorking ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]' : 'bg-text-3/30'
229
229
  }`} />
@@ -7,7 +7,8 @@ import { api } from '@/lib/api-client'
7
7
  import { BottomSheet } from '@/components/shared/bottom-sheet'
8
8
  import { toast } from 'sonner'
9
9
  import { ModelCombobox } from '@/components/shared/model-combobox'
10
- import type { ProviderType, ClaudeSkill } from '@/types'
10
+ import type { ProviderType, ClaudeSkill, AgentWallet } from '@/types'
11
+ import { WalletSection } from '@/components/wallets/wallet-section'
11
12
  import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
12
13
  import { NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
13
14
  import { AgentAvatar } from './agent-avatar'
@@ -105,12 +106,15 @@ export function AgentSheet() {
105
106
  const [openclawEnabled, setOpenclawEnabled] = useState(false)
106
107
  const [projectId, setProjectId] = useState<string | undefined>(undefined)
107
108
  const [avatarSeed, setAvatarSeed] = useState('')
109
+ const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
110
+ const [uploading, setUploading] = useState(false)
108
111
  const [thinkingLevel, setThinkingLevel] = useState<'' | 'minimal' | 'low' | 'medium' | 'high'>('')
109
112
  const [voiceId, setVoiceId] = useState('')
110
113
  const [heartbeatEnabled, setHeartbeatEnabled] = useState(false)
111
114
  const [heartbeatIntervalSec, setHeartbeatIntervalSec] = useState('') // '' = default (30m)
112
115
  const [heartbeatModel, setHeartbeatModel] = useState('')
113
116
  const [heartbeatPrompt, setHeartbeatPrompt] = useState('')
117
+ const [agentWallet, setAgentWallet] = useState<(Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }) | null>(null)
114
118
  const [addingKey, setAddingKey] = useState(false)
115
119
  const [newKeyName, setNewKeyName] = useState('')
116
120
  const [newKeyValue, setNewKeyValue] = useState('')
@@ -178,18 +182,27 @@ export function AgentSheet() {
178
182
  setMcpDisabledTools(editing.mcpDisabledTools || [])
179
183
  setFallbackCredentialIds(editing.fallbackCredentialIds || [])
180
184
  // platformAssignScope derived from isOrchestrator — no separate state
181
- setCapabilities(editing.capabilities || [])
185
+ setCapabilities(Array.isArray(editing.capabilities) ? editing.capabilities : [])
182
186
  setCapInput('')
183
187
  setOllamaMode(editing.credentialId && editing.provider === 'ollama' ? 'cloud' : 'local')
184
188
  setOpenclawEnabled(editing.provider === 'openclaw')
185
189
  setProjectId(editing.projectId)
186
190
  setAvatarSeed(editing.avatarSeed || crypto.randomUUID().slice(0, 8))
191
+ setAvatarUrl(editing.avatarUrl || null)
187
192
  setThinkingLevel(editing.thinkingLevel || '')
188
193
  setVoiceId(editing.elevenLabsVoiceId || '')
189
194
  setHeartbeatEnabled(editing.heartbeatEnabled || false)
190
195
  setHeartbeatIntervalSec(parseDurationToSec(editing.heartbeatInterval, editing.heartbeatIntervalSec))
191
196
  setHeartbeatModel(editing.heartbeatModel || '')
192
197
  setHeartbeatPrompt(editing.heartbeatPrompt || '')
198
+ // Load wallet if agent has one
199
+ if (editing.walletId) {
200
+ api<Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }>('GET', `/wallets/${editing.walletId}`)
201
+ .then(setAgentWallet)
202
+ .catch(() => setAgentWallet(null))
203
+ } else {
204
+ setAgentWallet(null)
205
+ }
193
206
  } else {
194
207
  setName('')
195
208
  setDescription('')
@@ -310,6 +323,7 @@ export function AgentSheet() {
310
323
  capabilities,
311
324
  projectId: projectId || undefined,
312
325
  avatarSeed: avatarSeed.trim() || undefined,
326
+ avatarUrl: avatarUrl || null,
313
327
  thinkingLevel: thinkingLevel || undefined,
314
328
  elevenLabsVoiceId: voiceId.trim() || null,
315
329
  heartbeatEnabled,
@@ -480,30 +494,82 @@ export function AgentSheet() {
480
494
 
481
495
  <div className="mb-8">
482
496
  <SectionLabel>Avatar</SectionLabel>
483
- <div className="flex items-center gap-3">
484
- <AgentAvatar seed={avatarSeed || null} name={name || 'A'} size={40} />
485
- <input
486
- type="text"
487
- value={avatarSeed}
488
- onChange={(e) => setAvatarSeed(e.target.value)}
489
- placeholder="Avatar seed (any text)"
490
- className={inputClass}
491
- style={{ fontFamily: 'inherit', flex: 1 }}
492
- />
493
- <button
494
- type="button"
495
- onClick={() => setAvatarSeed(crypto.randomUUID().slice(0, 8))}
496
- 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"
497
- style={{ fontFamily: 'inherit' }}
498
- title="Shuffle avatar"
499
- >
500
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
501
- <rect x="4" y="4" width="16" height="16" rx="2" />
502
- <circle cx="9" cy="9" r="1" fill="currentColor" />
503
- <circle cx="15" cy="15" r="1" fill="currentColor" />
504
- </svg>
505
- Shuffle
506
- </button>
497
+ <div className="flex flex-col gap-3">
498
+ <div className="flex items-center gap-4">
499
+ <div className="relative group shrink-0">
500
+ <AgentAvatar seed={avatarUrl ? null : (avatarSeed || null)} avatarUrl={avatarUrl} name={name || 'A'} size={64} />
501
+ <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">
502
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
503
+ <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" />
504
+ <circle cx="12" cy="13" r="4" />
505
+ </svg>
506
+ <input
507
+ type="file"
508
+ accept="image/*"
509
+ className="hidden"
510
+ onChange={async (e) => {
511
+ const file = e.target.files?.[0]
512
+ if (!file) return
513
+ setUploading(true)
514
+ try {
515
+ const res = await fetch('/api/upload', {
516
+ method: 'POST',
517
+ headers: { 'x-filename': file.name },
518
+ body: await file.arrayBuffer(),
519
+ })
520
+ const data = await res.json()
521
+ if (data.url) {
522
+ setAvatarUrl(data.url)
523
+ setAvatarSeed('')
524
+ }
525
+ } finally {
526
+ setUploading(false)
527
+ e.target.value = ''
528
+ }
529
+ }}
530
+ />
531
+ </label>
532
+ </div>
533
+ <div className="flex flex-col gap-1.5 flex-1 min-w-0">
534
+ {avatarUrl && (
535
+ <button
536
+ type="button"
537
+ onClick={() => {
538
+ setAvatarUrl(null)
539
+ if (!avatarSeed) setAvatarSeed(crypto.randomUUID().slice(0, 8))
540
+ }}
541
+ className="text-[11px] text-text-3 hover:text-red-400 transition-colors self-start cursor-pointer"
542
+ >
543
+ Remove custom image
544
+ </button>
545
+ )}
546
+ {uploading && <span className="text-[11px] text-text-3">Uploading...</span>}
547
+ </div>
548
+ </div>
549
+ <div className="flex items-center gap-3">
550
+ <input
551
+ type="text"
552
+ value={avatarSeed}
553
+ onChange={(e) => { setAvatarSeed(e.target.value); setAvatarUrl(null) }}
554
+ placeholder="Avatar seed (any text)"
555
+ className={inputClass}
556
+ style={{ fontFamily: 'inherit', flex: 1 }}
557
+ />
558
+ <button
559
+ type="button"
560
+ onClick={() => { setAvatarSeed(crypto.randomUUID().slice(0, 8)); setAvatarUrl(null) }}
561
+ 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"
562
+ style={{ fontFamily: 'inherit' }}
563
+ title="Shuffle avatar"
564
+ >
565
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
566
+ <rect x="4" y="4" width="16" height="16" rx="2" />
567
+ <circle cx="9" cy="9" r="1" fill="currentColor" />
568
+ <circle cx="15" cy="15" r="1" fill="currentColor" />
569
+ </svg>
570
+ Shuffle
571
+ </button>
572
+ </div>
507
573
  </div>
508
574
  </div>
509
575
 
@@ -666,6 +732,26 @@ export function AgentSheet() {
666
732
  <p className="text-[11px] text-text-3/70 mt-1.5">Periodic check-in runs on idle sessions using this agent. Processes pending events and monitors status.</p>
667
733
  </div>
668
734
 
735
+ {/* Wallet Section */}
736
+ {editingId && (
737
+ <WalletSection
738
+ agentId={editingId}
739
+ wallet={agentWallet}
740
+ onWalletCreated={async () => {
741
+ await loadAgents()
742
+ // Fetch the wallet for this agent
743
+ try {
744
+ const wallets = await api<Record<string, Omit<AgentWallet, 'encryptedPrivateKey'>>>('GET', '/wallets')
745
+ const match = Object.values(wallets).find((w) => w.agentId === editingId)
746
+ if (match) {
747
+ const detail = await api<Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }>('GET', `/wallets/${match.id}`)
748
+ setAgentWallet(detail)
749
+ }
750
+ } catch { /* ignore */ }
751
+ }}
752
+ />
753
+ )}
754
+
669
755
  {provider !== 'openclaw' && (
670
756
  <div className="mb-8">
671
757
  <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
@@ -2,11 +2,14 @@
2
2
 
3
3
  import { useState, useEffect } from 'react'
4
4
  import { setStoredAccessKey } from '@/lib/api-client'
5
+ import { fetchWithTimeout } from '@/lib/fetch-timeout'
5
6
 
6
7
  interface AccessKeyGateProps {
7
8
  onAuthenticated: () => void
8
9
  }
9
10
 
11
+ const AUTH_CHECK_TIMEOUT_MS = 8_000
12
+
10
13
  export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
11
14
  const [key, setKey] = useState('')
12
15
  const [error, setError] = useState('')
@@ -19,16 +22,22 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
19
22
  const [copied, setCopied] = useState(false)
20
23
 
21
24
  useEffect(() => {
22
- fetch('/api/auth')
23
- .then((r) => r.json())
24
- .then((data) => {
25
- if (data.firstTime && data.key) {
25
+ let cancelled = false
26
+ ;(async () => {
27
+ try {
28
+ const res = await fetchWithTimeout('/api/auth', {}, AUTH_CHECK_TIMEOUT_MS)
29
+ const data = await res.json().catch(() => ({}))
30
+ if (!cancelled && data.firstTime && data.key) {
26
31
  setFirstTime(true)
27
32
  setGeneratedKey(data.key)
28
33
  }
29
- })
30
- .catch((err) => console.error('Auth check failed:', err))
31
- .finally(() => setChecking(false))
34
+ } catch (err) {
35
+ console.error('Auth check failed:', err)
36
+ } finally {
37
+ if (!cancelled) setChecking(false)
38
+ }
39
+ })()
40
+ return () => { cancelled = true }
32
41
  }, [])
33
42
 
34
43
  const handleCopyKey = async () => {
@@ -44,14 +53,16 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
44
53
  const handleClaimKey = async () => {
45
54
  setLoading(true)
46
55
  try {
47
- const res = await fetch('/api/auth', {
56
+ const res = await fetchWithTimeout('/api/auth', {
48
57
  method: 'POST',
49
58
  headers: { 'Content-Type': 'application/json' },
50
59
  body: JSON.stringify({ key: generatedKey }),
51
- })
60
+ }, AUTH_CHECK_TIMEOUT_MS)
52
61
  if (res.ok) {
53
62
  setStoredAccessKey(generatedKey)
54
63
  onAuthenticated()
64
+ } else {
65
+ setError('Invalid access key')
55
66
  }
56
67
  } catch {
57
68
  setError('Connection failed')
@@ -69,11 +80,11 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
69
80
  setError('')
70
81
 
71
82
  try {
72
- const res = await fetch('/api/auth', {
83
+ const res = await fetchWithTimeout('/api/auth', {
73
84
  method: 'POST',
74
85
  headers: { 'Content-Type': 'application/json' },
75
86
  body: JSON.stringify({ key: trimmed }),
76
- })
87
+ }, AUTH_CHECK_TIMEOUT_MS)
77
88
  if (res.ok) {
78
89
  setStoredAccessKey(trimmed)
79
90
  onAuthenticated()
@@ -146,8 +146,8 @@ export function ChatArea() {
146
146
  if (!sessionId) return
147
147
  try {
148
148
  const msgs = await fetchMessages(sessionId)
149
- if (msgs.length > messagesLenRef.current) {
150
- const newMsgs = msgs.slice(messagesLenRef.current)
149
+ if (msgs.length !== messagesLenRef.current) {
150
+ const newMsgs = msgs.length > messagesLenRef.current ? msgs.slice(messagesLenRef.current) : []
151
151
  setMessages(msgs)
152
152
  if (ttsEnabledRef.current && typeof document !== 'undefined' && document.visibilityState === 'visible') {
153
153
  const latestAssistant = [...newMsgs].reverse().find((m) => {