@swarmclawai/swarmclaw 0.6.0 → 0.6.2

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 (109) hide show
  1. package/README.md +15 -2
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +3 -2
  15. package/src/app/api/tts/stream/route.ts +3 -2
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +46 -22
  31. package/src/components/chat/chat-header.tsx +455 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +180 -7
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +68 -16
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +51 -11
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/manager.ts +218 -7
  78. package/src/lib/server/heartbeat-service.ts +8 -1
  79. package/src/lib/server/main-agent-loop.ts +1 -1
  80. package/src/lib/server/memory-consolidation.ts +15 -2
  81. package/src/lib/server/memory-db.ts +134 -6
  82. package/src/lib/server/mime.ts +51 -0
  83. package/src/lib/server/openclaw-gateway.ts +2 -2
  84. package/src/lib/server/orchestrator-lg.ts +2 -0
  85. package/src/lib/server/orchestrator.ts +5 -2
  86. package/src/lib/server/playwright-proxy.mjs +2 -3
  87. package/src/lib/server/prompt-runtime-context.ts +53 -0
  88. package/src/lib/server/queue.ts +52 -7
  89. package/src/lib/server/session-tools/canvas.ts +67 -0
  90. package/src/lib/server/session-tools/connector.ts +83 -9
  91. package/src/lib/server/session-tools/crud.ts +21 -0
  92. package/src/lib/server/session-tools/delegate.ts +68 -4
  93. package/src/lib/server/session-tools/git.ts +71 -0
  94. package/src/lib/server/session-tools/http.ts +57 -0
  95. package/src/lib/server/session-tools/index.ts +8 -0
  96. package/src/lib/server/session-tools/memory.ts +1 -0
  97. package/src/lib/server/session-tools/search-providers.ts +16 -8
  98. package/src/lib/server/session-tools/subagent.ts +106 -0
  99. package/src/lib/server/session-tools/web.ts +115 -4
  100. package/src/lib/server/stream-agent-chat.ts +32 -10
  101. package/src/lib/server/task-mention.ts +41 -0
  102. package/src/lib/sessions.ts +10 -0
  103. package/src/lib/soul-library.ts +103 -0
  104. package/src/lib/task-dedupe.ts +26 -0
  105. package/src/lib/tool-definitions.ts +2 -0
  106. package/src/lib/tts.ts +2 -2
  107. package/src/stores/use-app-store.ts +5 -1
  108. package/src/stores/use-chat-store.ts +65 -2
  109. package/src/types/index.ts +32 -2
@@ -3,40 +3,7 @@ import { notFound } from '@/lib/server/collection-helpers'
3
3
  import fs from 'fs'
4
4
  import path from 'path'
5
5
  import { UPLOAD_DIR } from '@/lib/server/storage'
6
-
7
- const MIME_TYPES: Record<string, string> = {
8
- '.png': 'image/png',
9
- '.jpg': 'image/jpeg',
10
- '.jpeg': 'image/jpeg',
11
- '.gif': 'image/gif',
12
- '.webp': 'image/webp',
13
- '.svg': 'image/svg+xml',
14
- '.bmp': 'image/bmp',
15
- '.ico': 'image/x-icon',
16
- '.mp4': 'video/mp4',
17
- '.webm': 'video/webm',
18
- '.mov': 'video/quicktime',
19
- '.avi': 'video/x-msvideo',
20
- '.mkv': 'video/x-matroska',
21
- '.pdf': 'application/pdf',
22
- '.json': 'application/json',
23
- '.csv': 'text/csv',
24
- '.txt': 'text/plain',
25
- '.html': 'text/html',
26
- '.xml': 'application/xml',
27
- '.zip': 'application/zip',
28
- '.tar': 'application/x-tar',
29
- '.gz': 'application/gzip',
30
- '.doc': 'application/msword',
31
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
32
- '.xls': 'application/vnd.ms-excel',
33
- '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
34
- '.ppt': 'application/vnd.ms-powerpoint',
35
- '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
36
- '.mp3': 'audio/mpeg',
37
- '.wav': 'audio/wav',
38
- '.ogg': 'audio/ogg',
39
- }
6
+ import { MIME_TYPES } from '@/lib/server/mime'
40
7
 
41
8
  export async function GET(_req: Request, { params }: { params: Promise<{ filename: string }> }) {
42
9
  const { filename } = await params
@@ -60,3 +27,21 @@ export async function GET(_req: Request, { params }: { params: Promise<{ filenam
60
27
  },
61
28
  })
62
29
  }
30
+
31
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ filename: string }> }) {
32
+ const { filename } = await params
33
+ const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '')
34
+
35
+ if (safeName.includes('..') || safeName.includes('/')) {
36
+ return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
37
+ }
38
+
39
+ const filePath = path.join(UPLOAD_DIR, safeName)
40
+
41
+ if (!fs.existsSync(filePath)) {
42
+ return notFound()
43
+ }
44
+
45
+ fs.unlinkSync(filePath)
46
+ return NextResponse.json({ ok: true })
47
+ }
@@ -0,0 +1,94 @@
1
+ import { NextResponse } from 'next/server'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { UPLOAD_DIR } from '@/lib/server/storage'
5
+ import { getFileCategory } from '@/lib/server/mime'
6
+
7
+ interface UploadFile {
8
+ name: string
9
+ size: number
10
+ modified: number
11
+ category: string
12
+ url: string
13
+ }
14
+
15
+ function listUploadFiles(): UploadFile[] {
16
+ if (!fs.existsSync(UPLOAD_DIR)) return []
17
+ const entries = fs.readdirSync(UPLOAD_DIR)
18
+ const files: UploadFile[] = []
19
+ for (const name of entries) {
20
+ const filePath = path.join(UPLOAD_DIR, name)
21
+ try {
22
+ const stat = fs.statSync(filePath)
23
+ if (!stat.isFile()) continue
24
+ const ext = path.extname(name).toLowerCase()
25
+ files.push({
26
+ name,
27
+ size: stat.size,
28
+ modified: stat.mtimeMs,
29
+ category: getFileCategory(ext),
30
+ url: `/api/uploads/${encodeURIComponent(name)}`,
31
+ })
32
+ } catch {
33
+ // skip files we can't stat
34
+ }
35
+ }
36
+ return files
37
+ }
38
+
39
+ export async function GET() {
40
+ const files = listUploadFiles()
41
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0)
42
+ return NextResponse.json({ files, totalSize, count: files.length })
43
+ }
44
+
45
+ interface DeleteBody {
46
+ filenames?: string[]
47
+ olderThanDays?: number
48
+ category?: string
49
+ all?: boolean
50
+ }
51
+
52
+ function isUnsafeName(name: string): boolean {
53
+ return name.includes('/') || name.includes('\\') || name.includes('..')
54
+ }
55
+
56
+ export async function DELETE(req: Request) {
57
+ const body = (await req.json()) as DeleteBody
58
+ const files = listUploadFiles()
59
+ let toDelete: string[] = []
60
+
61
+ if (body.all) {
62
+ toDelete = files.map((f) => f.name)
63
+ } else if (body.filenames && Array.isArray(body.filenames)) {
64
+ for (const name of body.filenames) {
65
+ if (typeof name !== 'string' || isUnsafeName(name)) {
66
+ return NextResponse.json({ error: `Invalid filename: ${name}` }, { status: 400 })
67
+ }
68
+ }
69
+ toDelete = body.filenames
70
+ } else if (typeof body.olderThanDays === 'number') {
71
+ const cutoff = Date.now() - body.olderThanDays * 86_400_000
72
+ toDelete = files.filter((f) => f.modified < cutoff).map((f) => f.name)
73
+ } else if (typeof body.category === 'string') {
74
+ toDelete = files.filter((f) => f.category === body.category).map((f) => f.name)
75
+ } else {
76
+ return NextResponse.json({ error: 'Provide filenames, olderThanDays, category, or all' }, { status: 400 })
77
+ }
78
+
79
+ let deleted = 0
80
+ let freedBytes = 0
81
+ for (const name of toDelete) {
82
+ const filePath = path.join(UPLOAD_DIR, name)
83
+ try {
84
+ const stat = fs.statSync(filePath)
85
+ fs.unlinkSync(filePath)
86
+ freedBytes += stat.size
87
+ deleted++
88
+ } catch {
89
+ // file already gone or inaccessible
90
+ }
91
+ }
92
+
93
+ return NextResponse.json({ deleted, freedBytes })
94
+ }
@@ -269,6 +269,11 @@ textarea:hover::-webkit-scrollbar { width: 6px; }
269
269
  to { opacity: 0; transform: translateY(-8px) scale(0.95); }
270
270
  }
271
271
 
272
+ @keyframes delegation-handoff-in {
273
+ 0% { opacity: 0; transform: translateX(16px) scale(0.95); }
274
+ 100% { opacity: 1; transform: translateX(0) scale(1); }
275
+ }
276
+
272
277
  @keyframes avatar-moment-pulse {
273
278
  0% { transform: scale(1); }
274
279
  30% { transform: scale(1.15); }
package/src/cli/index.js CHANGED
@@ -79,6 +79,14 @@ const COMMAND_GROUPS = [
79
79
  }),
80
80
  ],
81
81
  },
82
+ {
83
+ name: 'canvas',
84
+ description: 'Read/update per-session canvas content',
85
+ commands: [
86
+ cmd('get', 'GET', '/canvas/:sessionId', 'Get current canvas content for a session'),
87
+ cmd('set', 'POST', '/canvas/:sessionId', 'Set/clear canvas content for a session', { expectsJsonBody: true }),
88
+ ],
89
+ },
82
90
  {
83
91
  name: 'connectors',
84
92
  description: 'Manage chat connectors',
@@ -154,6 +162,7 @@ const COMMAND_GROUPS = [
154
162
  description: 'Serve and manage local files',
155
163
  commands: [
156
164
  cmd('serve', 'GET', '/files/serve', 'Serve a local file (use --query path=/abs/path)'),
165
+ cmd('open', 'POST', '/files/open', 'Open a local file path via the host default app/browser', { expectsJsonBody: true }),
157
166
  ],
158
167
  },
159
168
  {
@@ -381,6 +390,8 @@ const COMMAND_GROUPS = [
381
390
  }),
382
391
  cmd('messages', 'GET', '/sessions/:id/messages', 'Get session messages'),
383
392
  cmd('messages-update', 'PUT', '/sessions/:id/messages', 'Update session message metadata (e.g. bookmark)', { expectsJsonBody: true }),
393
+ cmd('messages-send', 'POST', '/sessions/:id/messages', 'Append a user/system message to a session', { expectsJsonBody: true }),
394
+ cmd('messages-delete', 'DELETE', '/sessions/:id/messages', 'Delete a message from a session', { expectsJsonBody: true }),
384
395
  cmd('fork', 'POST', '/sessions/:id/fork', 'Fork session from a specific message index', { expectsJsonBody: true }),
385
396
  cmd('edit-resend', 'POST', '/sessions/:id/edit-resend', 'Edit and resend from a specific message index', { expectsJsonBody: true }),
386
397
  cmd('main-loop', 'GET', '/sessions/:id/main-loop', 'Get main mission loop state'),
@@ -455,6 +466,7 @@ const COMMAND_GROUPS = [
455
466
  cmd('delete', 'DELETE', '/tasks/:id', 'Delete task'),
456
467
  cmd('purge', 'DELETE', '/tasks', 'Bulk delete tasks', { expectsJsonBody: true }),
457
468
  cmd('approve', 'POST', '/tasks/:id/approve', 'Approve or reject a pending tool execution', { expectsJsonBody: true }),
469
+ cmd('metrics', 'GET', '/tasks/metrics', 'Get task board metrics (supports --query range=24h|7d|30d)'),
458
470
  ],
459
471
  },
460
472
  {
@@ -485,9 +497,12 @@ const COMMAND_GROUPS = [
485
497
  },
486
498
  {
487
499
  name: 'uploads',
488
- description: 'Fetch uploaded artifacts',
500
+ description: 'Manage uploaded artifacts',
489
501
  commands: [
502
+ cmd('list', 'GET', '/uploads', 'List uploaded artifacts'),
490
503
  cmd('get', 'GET', '/uploads/:filename', 'Download uploaded artifact', { responseType: 'binary' }),
504
+ cmd('delete', 'DELETE', '/uploads/:filename', 'Delete uploaded artifact by filename'),
505
+ cmd('delete-many', 'DELETE', '/uploads', 'Delete uploads by filter/body (filenames, olderThanDays, category, or all)', { expectsJsonBody: true }),
491
506
  ],
492
507
  },
493
508
  {
package/src/cli/spec.js CHANGED
@@ -40,6 +40,13 @@ const COMMAND_GROUPS = {
40
40
  pin: { description: 'Toggle pin on a chatroom message', method: 'POST', path: '/chatrooms/:id/pins', params: ['id'] },
41
41
  },
42
42
  },
43
+ canvas: {
44
+ description: 'Session canvas content',
45
+ commands: {
46
+ get: { description: 'Get current canvas content for a session', method: 'GET', path: '/canvas/:sessionId', params: ['sessionId'] },
47
+ set: { description: 'Set/clear canvas content for a session', method: 'POST', path: '/canvas/:sessionId', params: ['sessionId'] },
48
+ },
49
+ },
43
50
  connectors: {
44
51
  description: 'Manage chat connectors',
45
52
  commands: {
@@ -120,6 +127,22 @@ const COMMAND_GROUPS = {
120
127
  },
121
128
  },
122
129
  },
130
+ uploads: {
131
+ description: 'Manage uploaded artifacts',
132
+ commands: {
133
+ list: { description: 'List uploaded artifacts', method: 'GET', path: '/uploads' },
134
+ get: { description: 'Download uploaded artifact by filename', method: 'GET', path: '/uploads/:filename', params: ['filename'], binary: true },
135
+ delete: { description: 'Delete uploaded artifact by filename', method: 'DELETE', path: '/uploads/:filename', params: ['filename'] },
136
+ 'delete-many': { description: 'Delete uploads by filter/body (filenames, olderThanDays, category, or all)', method: 'DELETE', path: '/uploads' },
137
+ },
138
+ },
139
+ files: {
140
+ description: 'Serve/open local files',
141
+ commands: {
142
+ serve: { description: 'Serve a local file (supports --query path=/some/file)', method: 'GET', path: '/files/serve' },
143
+ open: { description: 'Open a local file path via host default app/browser', method: 'POST', path: '/files/open' },
144
+ },
145
+ },
123
146
  logs: {
124
147
  description: 'Application logs',
125
148
  commands: {
@@ -262,6 +285,8 @@ const COMMAND_GROUPS = {
262
285
  'heartbeat-disable-all': { description: 'Disable all session heartbeats and cancel queued heartbeat runs', method: 'POST', path: '/sessions/heartbeat' },
263
286
  messages: { description: 'Get session message history', method: 'GET', path: '/sessions/:id/messages', params: ['id'] },
264
287
  'messages-update': { description: 'Update session message metadata (e.g. bookmark)', method: 'PUT', path: '/sessions/:id/messages', params: ['id'] },
288
+ 'messages-send': { description: 'Append a user/system message to a session', method: 'POST', path: '/sessions/:id/messages', params: ['id'] },
289
+ 'messages-delete': { description: 'Delete a message from a session', method: 'DELETE', path: '/sessions/:id/messages', params: ['id'] },
265
290
  fork: { description: 'Fork session from a specific message index', method: 'POST', path: '/sessions/:id/fork', params: ['id'] },
266
291
  'edit-resend': { description: 'Edit and resend from a specific message index', method: 'POST', path: '/sessions/:id/edit-resend', params: ['id'] },
267
292
  'main-loop': { description: 'Get main mission loop state for a session', method: 'GET', path: '/sessions/:id/main-loop', params: ['id'] },
@@ -323,6 +348,7 @@ const COMMAND_GROUPS = {
323
348
  delete: { description: 'Archive task', method: 'DELETE', path: '/tasks/:id', params: ['id'] },
324
349
  archive: { description: 'Archive task', method: 'DELETE', path: '/tasks/:id', params: ['id'] },
325
350
  approve: { description: 'Approve or reject a pending tool execution', method: 'POST', path: '/tasks/:id/approve', params: ['id'] },
351
+ metrics: { description: 'Get task board metrics (supports --query range=24h|7d|30d)', method: 'GET', path: '/tasks/metrics' },
326
352
  },
327
353
  },
328
354
  webhooks: {
@@ -164,8 +164,8 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
164
164
  />
165
165
  <span className="font-display text-[14px] font-600 truncate flex-1 tracking-[-0.01em]">{agent.name}</span>
166
166
  {pendingApprovalCount > 0 && (
167
- <span className="shrink-0 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-700">
168
- {pendingApprovalCount}
167
+ <span className="shrink-0 text-[9px] font-600 uppercase tracking-wider px-2 py-0.5 rounded-[6px] text-amber-400 bg-amber-400/[0.08] border border-amber-400/15">
168
+ {pendingApprovalCount} {pendingApprovalCount === 1 ? 'approval' : 'approvals'}
169
169
  </span>
170
170
  )}
171
171
  {isDefault && (
@@ -178,7 +178,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
178
178
  onClick={handleRunClick}
179
179
  disabled={running}
180
180
  className="shrink-0 text-[10px] font-600 uppercase tracking-wider px-2.5 py-1 rounded-[6px] cursor-pointer
181
- transition-all border-none bg-accent-bright/20 text-[#818CF8] hover:bg-accent-bright/30 disabled:opacity-40"
181
+ transition-all border-none bg-accent-bright/20 text-accent-bright hover:bg-accent-bright/30 disabled:opacity-40"
182
182
  style={{ fontFamily: 'inherit' }}
183
183
  >
184
184
  {running ? '...' : 'Run'}
@@ -3,6 +3,7 @@
3
3
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
+ import { useChatroomStore } from '@/stores/use-chatroom-store'
6
7
  import { fetchMessages } from '@/lib/sessions'
7
8
  import type { Agent, Session } from '@/types'
8
9
  import { AgentAvatar } from './agent-avatar'
@@ -28,6 +29,8 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
28
29
  const streamingSessionId = useChatStore((s) => s.streamingSessionId)
29
30
  const chatFilter = useAppStore((s) => s.chatFilter ?? 'all')
30
31
  const setChatFilter = useAppStore((s) => s.setChatFilter)
32
+ const chatrooms = useChatroomStore((s) => s.chatrooms)
33
+ const chatroomStreaming = useChatroomStore((s) => s.streamingAgents)
31
34
  const [search, setSearch] = useState('')
32
35
 
33
36
  // FLIP animation refs
@@ -57,6 +60,21 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
57
60
  })
58
61
  }, [agents, sessions, search])
59
62
 
63
+ // Compute agents active in chatrooms (message in last 30min or currently streaming)
64
+ const chatroomActiveAgentIds = useMemo(() => {
65
+ const set = new Set<string>()
66
+ const cutoff = Date.now() - 30 * 60 * 1000
67
+ for (const chatroom of Object.values(chatrooms)) {
68
+ for (let i = chatroom.messages.length - 1; i >= 0; i--) {
69
+ const msg = chatroom.messages[i]
70
+ if (msg.time < cutoff) break
71
+ if (msg.role === 'assistant' && msg.senderId !== 'user') set.add(msg.senderId)
72
+ }
73
+ }
74
+ for (const agentId of chatroomStreaming.keys()) set.add(agentId)
75
+ return set
76
+ }, [chatrooms, chatroomStreaming])
77
+
60
78
  // Compute running tasks per agent
61
79
  const runningAgentIds = useMemo(() => {
62
80
  const set = new Set<string>()
@@ -74,12 +92,13 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
74
92
  const threadSession = a.threadSessionId ? sessions[a.threadSessionId] as Session | undefined : undefined
75
93
  const isRunning = runningAgentIds.has(a.id) || (threadSession?.active ?? false)
76
94
  const isStreaming = streamingSessionId === a.threadSessionId
77
- if (chatFilter === 'active') return isRunning || isStreaming
95
+ const isChatroomActive = chatroomActiveAgentIds.has(a.id)
96
+ if (chatFilter === 'active') return isRunning || isStreaming || isChatroomActive
78
97
  // 'recent' — activity within 24h
79
98
  const lastActive = threadSession?.lastActiveAt || a.updatedAt
80
99
  return now - lastActive < 86_400_000
81
100
  })
82
- }, [sortedAgents, chatFilter, sessions, runningAgentIds, streamingSessionId])
101
+ }, [sortedAgents, chatFilter, sessions, runningAgentIds, streamingSessionId, chatroomActiveAgentIds])
83
102
 
84
103
  // FLIP: animate row position changes
85
104
  useLayoutEffect(() => {
@@ -109,7 +128,9 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
109
128
  try {
110
129
  const msgs = await fetchMessages(state.currentSessionId)
111
130
  setMessages(msgs)
112
- } catch { /* ignore */ }
131
+ } catch (err: unknown) {
132
+ console.error('[agent-chat-list] Failed to load messages:', err instanceof Error ? err.message : String(err))
133
+ }
113
134
  }
114
135
  onSelect?.()
115
136
  // Delay scroll so React renders the new messages first
@@ -186,7 +207,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
186
207
  const isActive = currentAgentId === agent.id
187
208
  const heartbeatOn = agent.heartbeatEnabled === true && (agent.tools?.length ?? 0) > 0
188
209
  const recentlyActive = (threadSession?.lastActiveAt ?? 0) > Date.now() - 30 * 60 * 1000
189
- const isWorking = runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive
210
+ const isWorking = runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive || chatroomActiveAgentIds.has(agent.id)
190
211
  const isTyping = streamingSessionId === agent.threadSessionId
191
212
  const preview = lastMsg?.text?.slice(0, 80)?.replace(/\n/g, ' ') || ''
192
213
 
@@ -194,7 +215,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
194
215
  <div
195
216
  key={agent.id}
196
217
  ref={(el) => setRowRef(agent.id, el)}
197
- className={`group/row relative w-full text-left py-3 px-3.5 rounded-[12px] cursor-pointer transition-all duration-150 border-none
218
+ className={`group/row relative w-full text-left py-3 px-4 rounded-[12px] cursor-pointer transition-all duration-150 border-none
198
219
  ${isActive
199
220
  ? 'bg-accent-soft/80 border border-accent-bright/20'
200
221
  : 'bg-transparent hover:bg-white/[0.02]'}`}
@@ -213,7 +234,9 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
213
234
  {agent.name}
214
235
  </span>
215
236
  <span className="text-[10px] text-text-3/60 font-mono shrink-0">
216
- {agent.model ? agent.model.split('/').pop()?.split(':')[0] : agent.provider}
237
+ {(threadSession?.model || agent.model)
238
+ ? (threadSession?.model || agent.model)!.split('/').pop()?.split(':')[0]
239
+ : agent.provider}
217
240
  </span>
218
241
  {/* Set as default agent */}
219
242
  {(() => {
@@ -14,8 +14,9 @@ import { AgentAvatar } from './agent-avatar'
14
14
  import { AgentPickerList } from '@/components/shared/agent-picker-list'
15
15
  import { randomSoul } from '@/lib/soul-suggestions'
16
16
  import { SectionLabel } from '@/components/shared/section-label'
17
+ import { SoulLibraryPicker } from './soul-library-picker'
17
18
 
18
- const HB_PRESETS = [30, 60, 120, 300, 600, 1800, 3600] as const
19
+ const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const
19
20
 
20
21
  function formatHbDuration(sec: number): string {
21
22
  if (sec >= 3600) {
@@ -63,6 +64,7 @@ export function AgentSheet() {
63
64
  const loadProviders = useAppStore((s) => s.loadProviders)
64
65
  const credentials = useAppStore((s) => s.credentials)
65
66
  const loadCredentials = useAppStore((s) => s.loadCredentials)
67
+ const appSettings = useAppStore((s) => s.appSettings)
66
68
  const dynamicSkills = useAppStore((s) => s.skills)
67
69
  const mcpServers = useAppStore((s) => s.mcpServers)
68
70
  const loadSkills = useAppStore((s) => s.loadSkills)
@@ -80,6 +82,8 @@ export function AgentSheet() {
80
82
  const [name, setName] = useState('')
81
83
  const [description, setDescription] = useState('')
82
84
  const [soul, setSoul] = useState('')
85
+ const [soulInitial, setSoulInitial] = useState('')
86
+ const [soulSaveState, setSoulSaveState] = useState<'idle' | 'saved'>('idle')
83
87
  const [systemPrompt, setSystemPrompt] = useState('')
84
88
  const [provider, setProvider] = useState<ProviderType>('claude-cli')
85
89
  const [model, setModel] = useState('')
@@ -95,7 +99,6 @@ export function AgentSheet() {
95
99
  const [mcpTools, setMcpTools] = useState<Record<string, { name: string; description: string }[]>>({})
96
100
  const [mcpToolsLoading, setMcpToolsLoading] = useState(false)
97
101
  const [fallbackCredentialIds, setFallbackCredentialIds] = useState<string[]>([])
98
- // platformAssignScope is derived from isOrchestrator — no separate state needed
99
102
  const [capabilities, setCapabilities] = useState<string[]>([])
100
103
  const [capInput, setCapInput] = useState('')
101
104
  const [ollamaMode, setOllamaMode] = useState<'local' | 'cloud'>('local')
@@ -103,6 +106,7 @@ export function AgentSheet() {
103
106
  const [projectId, setProjectId] = useState<string | undefined>(undefined)
104
107
  const [avatarSeed, setAvatarSeed] = useState('')
105
108
  const [thinkingLevel, setThinkingLevel] = useState<'' | 'minimal' | 'low' | 'medium' | 'high'>('')
109
+ const [voiceId, setVoiceId] = useState('')
106
110
  const [heartbeatEnabled, setHeartbeatEnabled] = useState(false)
107
111
  const [heartbeatIntervalSec, setHeartbeatIntervalSec] = useState('') // '' = default (30m)
108
112
  const [heartbeatModel, setHeartbeatModel] = useState('')
@@ -121,6 +125,7 @@ export function AgentSheet() {
121
125
  const [configCopied, setConfigCopied] = useState(false)
122
126
 
123
127
  const soulFileRef = useRef<HTMLInputElement>(null)
128
+ const [soulLibraryOpen, setSoulLibraryOpen] = useState(false)
124
129
  const promptFileRef = useRef<HTMLInputElement>(null)
125
130
  const importFileRef = useRef<HTMLInputElement>(null)
126
131
 
@@ -157,6 +162,8 @@ export function AgentSheet() {
157
162
  setName(editing.name)
158
163
  setDescription(editing.description)
159
164
  setSoul(editing.soul || '')
165
+ setSoulInitial(editing.soul || '')
166
+ setSoulSaveState('idle')
160
167
  setSystemPrompt(editing.systemPrompt)
161
168
  setProvider(editing.provider)
162
169
  setModel(editing.model)
@@ -178,6 +185,7 @@ export function AgentSheet() {
178
185
  setProjectId(editing.projectId)
179
186
  setAvatarSeed(editing.avatarSeed || crypto.randomUUID().slice(0, 8))
180
187
  setThinkingLevel(editing.thinkingLevel || '')
188
+ setVoiceId(editing.elevenLabsVoiceId || '')
181
189
  setHeartbeatEnabled(editing.heartbeatEnabled || false)
182
190
  setHeartbeatIntervalSec(parseDurationToSec(editing.heartbeatInterval, editing.heartbeatIntervalSec))
183
191
  setHeartbeatModel(editing.heartbeatModel || '')
@@ -185,7 +193,10 @@ export function AgentSheet() {
185
193
  } else {
186
194
  setName('')
187
195
  setDescription('')
188
- setSoul(randomSoul())
196
+ const newSoul = randomSoul()
197
+ setSoul(newSoul)
198
+ setSoulInitial(newSoul)
199
+ setSoulSaveState('idle')
189
200
  setSystemPrompt('')
190
201
  setProvider('claude-cli')
191
202
  setModel('')
@@ -205,6 +216,7 @@ export function AgentSheet() {
205
216
  setProjectId(undefined)
206
217
  setAvatarSeed('')
207
218
  setThinkingLevel('')
219
+ setVoiceId('')
208
220
  setHeartbeatEnabled(false)
209
221
  setHeartbeatIntervalSec('')
210
222
  setHeartbeatModel('')
@@ -299,6 +311,7 @@ export function AgentSheet() {
299
311
  projectId: projectId || undefined,
300
312
  avatarSeed: avatarSeed.trim() || undefined,
301
313
  thinkingLevel: thinkingLevel || undefined,
314
+ elevenLabsVoiceId: voiceId.trim() || null,
302
315
  heartbeatEnabled,
303
316
  heartbeatInterval: heartbeatIntervalSec ? formatHbDuration(Number(heartbeatIntervalSec)) : null,
304
317
  heartbeatIntervalSec: heartbeatIntervalSec ? Number(heartbeatIntervalSec) : null,
@@ -313,6 +326,9 @@ export function AgentSheet() {
313
326
  toast.success('Agent created')
314
327
  }
315
328
  await loadAgents()
329
+ setSoulInitial(soul)
330
+ setSoulSaveState('saved')
331
+ setTimeout(() => setSoulSaveState('idle'), 1500)
316
332
  onClose()
317
333
  }
318
334
 
@@ -419,6 +435,7 @@ export function AgentSheet() {
419
435
  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"
420
436
 
421
437
  return (
438
+ <>
422
439
  <BottomSheet open={open} onClose={onClose} wide>
423
440
  <div className="mb-10 flex items-start justify-between">
424
441
  <div>
@@ -577,6 +594,24 @@ export function AgentSheet() {
577
594
  <p className="text-[11px] text-text-3/70 mt-1.5">Controls reasoning depth. Anthropic models use extended thinking; OpenAI o-series uses reasoning_effort. Others get system prompt guidance.</p>
578
595
  </div>
579
596
 
597
+ {/* ElevenLabs Voice ID */}
598
+ {appSettings.elevenLabsEnabled && (
599
+ <div className="mb-8">
600
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
601
+ ElevenLabs Voice ID <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
602
+ </label>
603
+ <input
604
+ type="text"
605
+ value={voiceId}
606
+ onChange={(e) => setVoiceId(e.target.value)}
607
+ placeholder="Leave blank for global default"
608
+ className={inputClass}
609
+ style={{ fontFamily: 'inherit' }}
610
+ />
611
+ <p className="text-[11px] text-text-3/70 mt-1.5">Override the default voice for this agent. Leave blank to use the global default.</p>
612
+ </div>
613
+ )}
614
+
580
615
  {/* Heartbeat Configuration */}
581
616
  <div className="mb-8">
582
617
  <div className="flex items-center justify-between mb-3">
@@ -633,8 +668,20 @@ export function AgentSheet() {
633
668
 
634
669
  {provider !== 'openclaw' && (
635
670
  <div className="mb-8">
636
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
671
+ <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
637
672
  Soul / Personality <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
673
+ {soul !== soulInitial && soulSaveState === 'idle' && (
674
+ <span className="inline-flex items-center gap-1 normal-case tracking-normal text-[10px] text-amber-400 font-600">
675
+ <span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
676
+ Unsaved
677
+ </span>
678
+ )}
679
+ {soulSaveState === 'saved' && (
680
+ <span className="inline-flex items-center gap-1 normal-case tracking-normal text-[10px] text-emerald-400 font-600">
681
+ <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>
682
+ Saved
683
+ </span>
684
+ )}
638
685
  </label>
639
686
  <div className="flex items-center gap-2 mb-3">
640
687
  <p className="text-[12px] text-text-3/60">Define the agent&apos;s voice, tone, and personality. Injected before the system prompt.</p>
@@ -652,6 +699,14 @@ export function AgentSheet() {
652
699
  </svg>
653
700
  Shuffle
654
701
  </button>
702
+ <button
703
+ type="button"
704
+ onClick={() => setSoulLibraryOpen(true)}
705
+ 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"
706
+ style={{ fontFamily: 'inherit' }}
707
+ >
708
+ Browse Library
709
+ </button>
655
710
  <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>
656
711
  <input ref={soulFileRef} type="file" accept=".md,.txt,.markdown" onChange={handleFileUpload(setSoul)} className="hidden" />
657
712
  </div>
@@ -1372,6 +1427,13 @@ export function AgentSheet() {
1372
1427
  </button>
1373
1428
  </div>
1374
1429
  </BottomSheet>
1430
+
1431
+ <SoulLibraryPicker
1432
+ open={soulLibraryOpen}
1433
+ onClose={() => setSoulLibraryOpen(false)}
1434
+ onSelect={(s) => setSoul(s)}
1435
+ />
1436
+ </>
1375
1437
  )
1376
1438
  }
1377
1439