@swarmclawai/swarmclaw 0.3.0 → 0.4.0

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 (118) hide show
  1. package/README.md +20 -11
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +2 -0
  6. package/package.json +3 -1
  7. package/src/app/api/agents/[id]/route.ts +3 -0
  8. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  9. package/src/app/api/agents/route.ts +5 -1
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/connectors/[id]/route.ts +4 -0
  13. package/src/app/api/connectors/route.ts +6 -1
  14. package/src/app/api/credentials/route.ts +3 -1
  15. package/src/app/api/daemon/route.ts +6 -1
  16. package/src/app/api/ip/route.ts +3 -1
  17. package/src/app/api/mcp-servers/route.ts +3 -1
  18. package/src/app/api/orchestrator/graph/route.ts +25 -0
  19. package/src/app/api/plugins/marketplace/route.ts +3 -1
  20. package/src/app/api/plugins/route.ts +3 -1
  21. package/src/app/api/providers/[id]/route.ts +3 -0
  22. package/src/app/api/providers/configs/route.ts +3 -1
  23. package/src/app/api/providers/route.ts +5 -1
  24. package/src/app/api/schedules/[id]/route.ts +3 -0
  25. package/src/app/api/schedules/route.ts +6 -1
  26. package/src/app/api/secrets/route.ts +3 -1
  27. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  28. package/src/app/api/sessions/route.ts +9 -2
  29. package/src/app/api/settings/route.ts +3 -1
  30. package/src/app/api/setup/doctor/route.ts +1 -0
  31. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  32. package/src/app/api/skills/route.ts +3 -1
  33. package/src/app/api/tasks/[id]/approve/route.ts +73 -0
  34. package/src/app/api/tasks/[id]/route.ts +3 -0
  35. package/src/app/api/tasks/route.ts +3 -0
  36. package/src/app/api/usage/route.ts +3 -1
  37. package/src/app/api/version/route.ts +3 -1
  38. package/src/app/api/webhooks/[id]/route.ts +2 -1
  39. package/src/app/api/webhooks/route.ts +3 -1
  40. package/src/app/icon.svg +58 -0
  41. package/src/app/page.tsx +8 -2
  42. package/src/cli/index.js +1 -9
  43. package/src/cli/index.ts +51 -1
  44. package/src/cli/spec.js +0 -8
  45. package/src/components/agents/agent-card.tsx +1 -1
  46. package/src/components/agents/agent-sheet.tsx +63 -80
  47. package/src/components/chat/chat-area.tsx +44 -30
  48. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  49. package/src/components/chat/message-bubble.tsx +110 -42
  50. package/src/components/chat/tool-call-bubble.tsx +41 -3
  51. package/src/components/chat/tool-request-banner.tsx +1 -9
  52. package/src/components/connectors/connector-list.tsx +3 -8
  53. package/src/components/connectors/connector-sheet.tsx +24 -29
  54. package/src/components/input/chat-input.tsx +72 -56
  55. package/src/components/knowledge/knowledge-list.tsx +27 -31
  56. package/src/components/layout/app-layout.tsx +92 -71
  57. package/src/components/layout/daemon-indicator.tsx +3 -5
  58. package/src/components/logs/log-list.tsx +5 -9
  59. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  60. package/src/components/memory/memory-detail.tsx +1 -1
  61. package/src/components/plugins/plugin-list.tsx +227 -27
  62. package/src/components/providers/provider-list.tsx +46 -13
  63. package/src/components/providers/provider-sheet.tsx +0 -45
  64. package/src/components/runs/run-list.tsx +6 -15
  65. package/src/components/schedules/schedule-card.tsx +54 -4
  66. package/src/components/schedules/schedule-list.tsx +6 -3
  67. package/src/components/schedules/schedule-sheet.tsx +0 -47
  68. package/src/components/secrets/secrets-list.tsx +20 -2
  69. package/src/components/sessions/new-session-sheet.tsx +8 -9
  70. package/src/components/shared/connector-platform-icon.tsx +22 -20
  71. package/src/components/shared/model-combobox.tsx +148 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +7 -39
  73. package/src/components/shared/settings/section-orchestrator.tsx +8 -9
  74. package/src/components/skills/skill-list.tsx +260 -34
  75. package/src/components/skills/skill-sheet.tsx +0 -45
  76. package/src/components/tasks/task-board.tsx +3 -6
  77. package/src/components/tasks/task-card.tsx +43 -1
  78. package/src/components/tasks/task-list.tsx +3 -5
  79. package/src/components/tasks/task-sheet.tsx +0 -44
  80. package/src/components/usage/usage-list.tsx +12 -4
  81. package/src/hooks/use-ws.ts +66 -0
  82. package/src/instrumentation.ts +2 -0
  83. package/src/lib/chat.ts +14 -2
  84. package/src/lib/providers/anthropic.ts +1 -1
  85. package/src/lib/providers/index.ts +2 -0
  86. package/src/lib/providers/ollama.ts +1 -1
  87. package/src/lib/providers/openai.ts +33 -12
  88. package/src/lib/server/chat-execution.ts +19 -4
  89. package/src/lib/server/connectors/manager.ts +9 -3
  90. package/src/lib/server/context-manager.ts +1 -1
  91. package/src/lib/server/daemon-state.ts +3 -0
  92. package/src/lib/server/data-dir.ts +1 -0
  93. package/src/lib/server/heartbeat-service.ts +67 -3
  94. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  95. package/src/lib/server/main-agent-loop.ts +61 -2
  96. package/src/lib/server/orchestrator-lg.ts +394 -13
  97. package/src/lib/server/orchestrator.ts +25 -5
  98. package/src/lib/server/queue.ts +17 -3
  99. package/src/lib/server/session-run-manager.ts +6 -1
  100. package/src/lib/server/session-tools/delegate.ts +2 -2
  101. package/src/lib/server/session-tools/index.ts +2 -0
  102. package/src/lib/server/session-tools/sandbox.ts +164 -0
  103. package/src/lib/server/storage-mcp.test.ts +25 -2
  104. package/src/lib/server/storage.ts +24 -7
  105. package/src/lib/server/stream-agent-chat.ts +77 -22
  106. package/src/lib/server/task-validation.test.ts +23 -0
  107. package/src/lib/server/task-validation.ts +5 -3
  108. package/src/lib/server/ws-hub.ts +85 -0
  109. package/src/lib/tool-definitions.ts +42 -0
  110. package/src/lib/upload.ts +7 -1
  111. package/src/lib/ws-client.ts +124 -0
  112. package/src/stores/use-chat-store.ts +33 -13
  113. package/src/types/index.ts +8 -1
  114. package/src/app/api/agents/generate/route.ts +0 -42
  115. package/src/app/api/generate/info/route.ts +0 -12
  116. package/src/app/api/generate/route.ts +0 -106
  117. package/src/app/favicon.ico +0 -0
  118. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -5,38 +5,10 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { createAgent, updateAgent, deleteAgent } from '@/lib/agents'
6
6
  import { api } from '@/lib/api-client'
7
7
  import { BottomSheet } from '@/components/shared/bottom-sheet'
8
- import { AiGenBlock } from '@/components/shared/ai-gen-block'
9
8
  import { toast } from 'sonner'
9
+ import { ModelCombobox } from '@/components/shared/model-combobox'
10
10
  import type { ProviderType, ClaudeSkill } from '@/types'
11
-
12
- const AVAILABLE_TOOLS: { id: string; label: string; description: string }[] = [
13
- { id: 'shell', label: 'Shell', description: 'Execute commands in the working directory' },
14
- { id: 'files', label: 'Files', description: 'Read, write, list, move, copy, and send files' },
15
- { id: 'copy_file', label: 'Copy File', description: 'Copy files within the working directory' },
16
- { id: 'move_file', label: 'Move File', description: 'Move/rename files within the working directory' },
17
- { id: 'delete_file', label: 'Delete File', description: 'Delete files/directories (disabled by default)' },
18
- { id: 'edit_file', label: 'Edit File', description: 'Search-and-replace editing within files' },
19
- { id: 'process', label: 'Process', description: 'Monitor and control long-running shell commands' },
20
- { id: 'web_search', label: 'Web Search', description: 'Search the web via DuckDuckGo' },
21
- { id: 'web_fetch', label: 'Web Fetch', description: 'Fetch and extract text from URLs' },
22
- { id: 'claude_code', label: 'Claude Code', description: 'Delegate complex tasks to Claude Code CLI' },
23
- { id: 'codex_cli', label: 'Codex CLI', description: 'Delegate complex tasks to OpenAI Codex CLI' },
24
- { id: 'opencode_cli', label: 'OpenCode CLI', description: 'Delegate complex tasks to OpenCode CLI' },
25
- { id: 'browser', label: 'Browser', description: 'Playwright — browse, scrape, interact with web pages' },
26
- { id: 'memory', label: 'Memory', description: 'Store and retrieve long-term memories across sessions' },
27
- ]
28
-
29
- const PLATFORM_TOOLS: { id: string; label: string; description: string }[] = [
30
- { id: 'manage_agents', label: 'Agents', description: 'Create, edit, and delete agents' },
31
- { id: 'manage_tasks', label: 'Tasks', description: 'Create, edit, and delete tasks' },
32
- { id: 'manage_schedules', label: 'Schedules', description: 'Create, edit, and delete schedules' },
33
- { id: 'manage_skills', label: 'Skills', description: 'Create, edit, and delete skills' },
34
- { id: 'manage_documents', label: 'Documents', description: 'Upload, search, and delete indexed documents' },
35
- { id: 'manage_webhooks', label: 'Webhooks', description: 'Register webhooks that trigger agent sessions' },
36
- { id: 'manage_connectors', label: 'Connectors', description: 'Create, edit, and delete connectors' },
37
- { id: 'manage_sessions', label: 'Sessions', description: 'List sessions, send messages, and spawn session work' },
38
- { id: 'manage_secrets', label: 'Secrets', description: 'Store and retrieve encrypted service secrets' },
39
- ]
11
+ import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
40
12
 
41
13
  const NATIVE_CAPABILITY_PROVIDER_IDS = new Set<ProviderType>(['claude-cli', 'codex-cli', 'opencode-cli', 'openclaw'])
42
14
 
@@ -54,9 +26,6 @@ export function AgentSheet() {
54
26
  const dynamicSkills = useAppStore((s) => s.skills)
55
27
  const mcpServers = useAppStore((s) => s.mcpServers)
56
28
  const loadSkills = useAppStore((s) => s.loadSkills)
57
- const appSettings = useAppStore((s) => s.appSettings)
58
- const loadSettings = useAppStore((s) => s.loadSettings)
59
-
60
29
  const [claudeSkills, setClaudeSkills] = useState<ClaudeSkill[]>([])
61
30
  const [claudeSkillsLoading, setClaudeSkillsLoading] = useState(false)
62
31
  const loadClaudeSkills = async () => {
@@ -91,6 +60,9 @@ export function AgentSheet() {
91
60
  const [capInput, setCapInput] = useState('')
92
61
  const [ollamaMode, setOllamaMode] = useState<'local' | 'cloud'>('local')
93
62
  const [openclawEnabled, setOpenclawEnabled] = useState(false)
63
+ const [heartbeatEnabled, setHeartbeatEnabled] = useState(false)
64
+ const [heartbeatInterval, setHeartbeatInterval] = useState('')
65
+ const [heartbeatModel, setHeartbeatModel] = useState('')
94
66
  const [addingKey, setAddingKey] = useState(false)
95
67
  const [newKeyName, setNewKeyName] = useState('')
96
68
  const [newKeyValue, setNewKeyValue] = useState('')
@@ -117,12 +89,6 @@ export function AgentSheet() {
117
89
  e.target.value = ''
118
90
  }
119
91
 
120
- // AI generation state
121
- const [aiPrompt, setAiPrompt] = useState('')
122
- const [generating, setGenerating] = useState(false)
123
- const [generated, setGenerated] = useState(false)
124
- const [genError, setGenError] = useState('')
125
-
126
92
  const currentProvider = providers.find((p) => p.id === provider)
127
93
  const providerCredentials = Object.values(credentials).filter((c) => c.provider === provider)
128
94
  const openclawCredentials = Object.values(credentials).filter((c) => c.provider === 'openclaw')
@@ -140,11 +106,6 @@ export function AgentSheet() {
140
106
  loadCredentials()
141
107
  loadSkills()
142
108
  loadClaudeSkills()
143
- loadSettings()
144
- setAiPrompt('')
145
- setGenerating(false)
146
- setGenerated(false)
147
- setGenError('')
148
109
  setTestStatus('idle')
149
110
  setTestMessage('')
150
111
  if (editing) {
@@ -169,6 +130,9 @@ export function AgentSheet() {
169
130
  setCapInput('')
170
131
  setOllamaMode(editing.credentialId && editing.provider === 'ollama' ? 'cloud' : 'local')
171
132
  setOpenclawEnabled(editing.provider === 'openclaw')
133
+ setHeartbeatEnabled(editing.heartbeatEnabled || false)
134
+ setHeartbeatInterval(editing.heartbeatInterval != null ? String(editing.heartbeatInterval) : '')
135
+ setHeartbeatModel(editing.heartbeatModel || '')
172
136
  } else {
173
137
  setName('')
174
138
  setDescription('')
@@ -190,6 +154,9 @@ export function AgentSheet() {
190
154
  setCapInput('')
191
155
  setOllamaMode('local')
192
156
  setOpenclawEnabled(false)
157
+ setHeartbeatEnabled(false)
158
+ setHeartbeatInterval('')
159
+ setHeartbeatModel('')
193
160
  }
194
161
  }
195
162
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -246,29 +213,6 @@ export function AgentSheet() {
246
213
  return () => { cancelled = true }
247
214
  }, [openclawEnabled])
248
215
 
249
- const handleGenerate = async () => {
250
- if (!aiPrompt.trim()) return
251
- setGenerating(true)
252
- setGenError('')
253
- try {
254
- const result = await api<{ name?: string; description?: string; systemPrompt?: string; isOrchestrator?: boolean; error?: string }>('POST', '/agents/generate', { prompt: aiPrompt })
255
- if (result.error) {
256
- setGenError(result.error)
257
- } else if (result.name || result.systemPrompt) {
258
- if (result.name) setName(result.name)
259
- if (result.description) setDescription(result.description)
260
- if (result.systemPrompt) setSystemPrompt(result.systemPrompt)
261
- if (result.isOrchestrator !== undefined) setIsOrchestrator(result.isOrchestrator)
262
- setGenerated(true)
263
- } else {
264
- setGenError('AI returned empty response — try again')
265
- }
266
- } catch (err: unknown) {
267
- setGenError(err instanceof Error ? err.message : 'Generation failed')
268
- }
269
- setGenerating(false)
270
- }
271
-
272
216
  const onClose = () => {
273
217
  setOpen(false)
274
218
  setEditingId(null)
@@ -300,6 +244,9 @@ export function AgentSheet() {
300
244
  fallbackCredentialIds,
301
245
  platformAssignScope,
302
246
  capabilities,
247
+ heartbeatEnabled,
248
+ heartbeatInterval: heartbeatInterval.trim() || null,
249
+ heartbeatModel: heartbeatModel.trim() || null,
303
250
  }
304
251
  if (editing) {
305
252
  await updateAgent(editing.id, data)
@@ -450,14 +397,6 @@ export function AgentSheet() {
450
397
  </div>
451
398
  </div>
452
399
 
453
- {/* AI Generation */}
454
- {!editing && !openclawEnabled && <AiGenBlock
455
- aiPrompt={aiPrompt} setAiPrompt={setAiPrompt}
456
- generating={generating} generated={generated} genError={genError}
457
- onGenerate={handleGenerate} appSettings={appSettings}
458
- placeholder='Describe the agent you want, e.g. "An SEO keyword researcher that finds low-competition long-tail keywords"'
459
- />}
460
-
461
400
  <div className="mb-8">
462
401
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Name</label>
463
402
  <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. SEO Researcher" className={inputClass} style={{ fontFamily: 'inherit' }} />
@@ -512,6 +451,47 @@ export function AgentSheet() {
512
451
  <p className="text-[11px] text-text-3/70 mt-1.5">Press Enter or comma to add. Other agents see these when deciding delegation.</p>
513
452
  </div>}
514
453
 
454
+ {/* Heartbeat Configuration */}
455
+ <div className="mb-8">
456
+ <div className="flex items-center justify-between mb-3">
457
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">Heartbeat</label>
458
+ <button
459
+ type="button"
460
+ onClick={() => setHeartbeatEnabled(!heartbeatEnabled)}
461
+ className={`relative w-10 h-[22px] rounded-full transition-colors duration-200 cursor-pointer ${heartbeatEnabled ? 'bg-accent' : 'bg-white/[0.12]'}`}
462
+ >
463
+ <span className={`absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200 ${heartbeatEnabled ? 'translate-x-[18px]' : ''}`} />
464
+ </button>
465
+ </div>
466
+ {heartbeatEnabled && (
467
+ <div className="space-y-4 mt-3">
468
+ <div>
469
+ <label className="block text-[12px] text-text-3/70 mb-1.5">Interval</label>
470
+ <input
471
+ type="text"
472
+ value={heartbeatInterval}
473
+ onChange={(e) => setHeartbeatInterval(e.target.value)}
474
+ placeholder="30s, 5m, 1h (default: 30m)"
475
+ className={inputClass}
476
+ style={{ fontFamily: 'inherit' }}
477
+ />
478
+ </div>
479
+ <div>
480
+ <label className="block text-[12px] text-text-3/70 mb-1.5">Model override <span className="text-text-3/50">(optional, cheaper model)</span></label>
481
+ <input
482
+ type="text"
483
+ value={heartbeatModel}
484
+ onChange={(e) => setHeartbeatModel(e.target.value)}
485
+ placeholder="e.g. gpt-4o-mini"
486
+ className={inputClass}
487
+ style={{ fontFamily: 'inherit' }}
488
+ />
489
+ </div>
490
+ </div>
491
+ )}
492
+ <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>
493
+ </div>
494
+
515
495
  {provider !== 'openclaw' && (
516
496
  <div className="mb-8">
517
497
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
@@ -774,11 +754,14 @@ export function AgentSheet() {
774
754
  {!openclawEnabled && currentProvider && currentProvider.models.length > 0 && (
775
755
  <div className="mb-8">
776
756
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Model</label>
777
- <select value={model} onChange={(e) => setModel(e.target.value)} className={`${inputClass} appearance-none cursor-pointer`} style={{ fontFamily: 'inherit' }}>
778
- {currentProvider.models.map((m) => (
779
- <option key={m} value={m}>{m}</option>
780
- ))}
781
- </select>
757
+ <ModelCombobox
758
+ providerId={currentProvider.id}
759
+ value={model}
760
+ onChange={setModel}
761
+ models={currentProvider.models}
762
+ defaultModels={currentProvider.defaultModels}
763
+ className={`${inputClass} cursor-pointer`}
764
+ />
782
765
  </div>
783
766
  )}
784
767
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useCallback, useState, useRef } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { useWs } from '@/hooks/use-ws'
5
6
  import { useChatStore } from '@/stores/use-chat-store'
6
7
  import { fetchMessages, clearMessages, deleteSession, devServer, checkBrowser, stopBrowser } from '@/lib/sessions'
7
8
  import { uploadImage } from '@/lib/upload'
@@ -90,33 +91,43 @@ export function ChatArea() {
90
91
  const isOrchestrated = session?.sessionType === 'orchestrated'
91
92
  const isServerActive = session?.active === true
92
93
  const isOngoingMonitored = appSettings.loopMode === 'ongoing' && !!session?.tools?.length
93
- useEffect(() => {
94
- if (!sessionId || (!isOrchestrated && !isServerActive && !isOngoingMonitored)) return
95
- const interval = setInterval(async () => {
96
- try {
97
- const msgs = await fetchMessages(sessionId)
98
- if (msgs.length > messages.length) {
99
- const newMsgs = msgs.slice(messages.length)
100
- setMessages(msgs)
101
- if (ttsEnabled && typeof document !== 'undefined' && document.visibilityState === 'visible') {
102
- const latestAssistant = [...newMsgs].reverse().find((m) => {
103
- if (m.role !== 'assistant') return false
104
- const isHeartbeat = m.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(m.text || '')
105
- return !isHeartbeat && !!m.text?.trim()
106
- })
107
- if (latestAssistant?.text) {
108
- void speak(latestAssistant.text)
109
- }
94
+ const shouldPollMessages = !!sessionId && (isOrchestrated || isServerActive || isOngoingMonitored)
95
+ const messagesLenRef = useRef(messages.length)
96
+ messagesLenRef.current = messages.length
97
+ const isServerActiveRef = useRef(isServerActive)
98
+ isServerActiveRef.current = isServerActive
99
+ const ttsEnabledRef = useRef(ttsEnabled)
100
+ ttsEnabledRef.current = ttsEnabled
101
+
102
+ const refreshMessages = useCallback(async () => {
103
+ if (!sessionId) return
104
+ try {
105
+ const msgs = await fetchMessages(sessionId)
106
+ if (msgs.length > messagesLenRef.current) {
107
+ const newMsgs = msgs.slice(messagesLenRef.current)
108
+ setMessages(msgs)
109
+ if (ttsEnabledRef.current && typeof document !== 'undefined' && document.visibilityState === 'visible') {
110
+ const latestAssistant = [...newMsgs].reverse().find((m) => {
111
+ if (m.role !== 'assistant') return false
112
+ const isHeartbeat = m.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(m.text || '')
113
+ return !isHeartbeat && !!m.text?.trim()
114
+ })
115
+ if (latestAssistant?.text) {
116
+ void speak(latestAssistant.text)
110
117
  }
111
118
  }
112
- // Check if session is still active on the server
113
- if (isServerActive) {
114
- await loadSessions()
115
- }
116
- } catch (err) { console.error('Failed to refresh messages:', err) }
117
- }, 2000)
118
- return () => clearInterval(interval)
119
- }, [sessionId, isOrchestrated, isServerActive, isOngoingMonitored, messages.length])
119
+ }
120
+ if (isServerActiveRef.current) await loadSessions()
121
+ } catch (err) { console.error('Failed to refresh messages:', err) }
122
+ }, [sessionId])
123
+
124
+ // Subscribe to WS messages for this session — always subscribe when session exists,
125
+ // only enable fallback polling when actively needed
126
+ useWs(
127
+ sessionId ? `messages:${sessionId}` : '',
128
+ refreshMessages,
129
+ shouldPollMessages ? 2000 : undefined,
130
+ )
120
131
 
121
132
  // When server-active flag drops, stop the streaming indicator
122
133
  useEffect(() => {
@@ -136,14 +147,17 @@ export function ChatArea() {
136
147
 
137
148
  // Poll browser status while session has browser tools
138
149
  const hasBrowserTool = session?.tools?.includes('browser')
139
- useEffect(() => {
150
+ const checkBrowserStatus = useCallback(() => {
140
151
  if (!sessionId || !hasBrowserTool) return
141
- const interval = setInterval(() => {
142
- checkBrowser(sessionId).then((r) => setBrowserActive(r.active)).catch(() => {})
143
- }, 5000)
144
- return () => clearInterval(interval)
152
+ checkBrowser(sessionId).then((r) => setBrowserActive(r.active)).catch(() => {})
145
153
  }, [sessionId, hasBrowserTool])
146
154
 
155
+ useWs(
156
+ hasBrowserTool && sessionId ? `browser:${sessionId}` : '',
157
+ checkBrowserStatus,
158
+ hasBrowserTool ? 5000 : undefined,
159
+ )
160
+
147
161
  const handleStopBrowser = useCallback(async () => {
148
162
  if (!sessionId) return
149
163
  await stopBrowser(sessionId)
@@ -3,54 +3,16 @@
3
3
  import { useState, useRef, useEffect } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
+ import { AVAILABLE_TOOLS, PLATFORM_TOOLS, TOOL_LABELS } from '@/lib/tool-definitions'
7
+ import type { ToolDefinition } from '@/lib/tool-definitions'
6
8
  import type { Session } from '@/types'
7
9
 
8
- const TOOL_GROUPS: { label: string; tools: Record<string, string> }[] = [
9
- {
10
- label: 'Tools',
11
- tools: {
12
- shell: 'Shell',
13
- files: 'Files',
14
- edit_file: 'Edit File',
15
- process: 'Process',
16
- web_search: 'Web Search',
17
- web_fetch: 'Web Fetch',
18
- browser: 'Browser',
19
- memory: 'Memory',
20
- },
21
- },
22
- {
23
- label: 'Delegation',
24
- tools: {
25
- claude_code: 'Claude Code',
26
- codex_cli: 'Codex CLI',
27
- opencode_cli: 'OpenCode CLI',
28
- },
29
- },
30
- {
31
- label: 'Platform',
32
- tools: {
33
- orchestrator: 'Orchestrator',
34
- manage_agents: 'Agents',
35
- manage_tasks: 'Tasks',
36
- manage_schedules: 'Schedules',
37
- manage_skills: 'Skills',
38
- manage_documents: 'Documents',
39
- manage_webhooks: 'Webhooks',
40
- manage_connectors: 'Connectors',
41
- manage_sessions: 'Sessions',
42
- manage_secrets: 'Secrets',
43
- },
44
- },
10
+ const TOOL_GROUPS: { label: string; tools: ToolDefinition[] }[] = [
11
+ { label: 'Tools', tools: AVAILABLE_TOOLS },
12
+ { label: 'Platform', tools: PLATFORM_TOOLS },
45
13
  ]
46
14
 
47
- // Flat lookup for display names
48
- const ALL_TOOLS: Record<string, string> = {}
49
- for (const g of TOOL_GROUPS) Object.assign(ALL_TOOLS, g.tools)
50
-
51
- const TOOL_HINTS: Record<string, string> = {
52
- orchestrator: 'Can delegate tasks to other agents',
53
- }
15
+ const TOTAL_TOOL_COUNT = AVAILABLE_TOOLS.length + PLATFORM_TOOLS.length
54
16
 
55
17
  interface Props {
56
18
  session: Session
@@ -87,7 +49,7 @@ export function ChatToolToggles({ session }: Props) {
87
49
  }
88
50
 
89
51
  const enabledCount = sessionTools.length
90
- const totalCount = Object.keys(ALL_TOOLS).length
52
+ const totalCount = TOTAL_TOOL_COUNT
91
53
 
92
54
  return (
93
55
  <div className="relative" ref={ref}>
@@ -111,12 +73,12 @@ export function ChatToolToggles({ session }: Props) {
111
73
  {TOOL_GROUPS.map((group, gi) => (
112
74
  <div key={group.label} className={`px-3 pb-1 ${gi === 0 ? 'pt-3' : 'pt-1 border-t border-white/[0.04]'}`}>
113
75
  <p className="text-[10px] font-600 text-text-3/60 uppercase tracking-wider mb-2">{group.label}</p>
114
- {Object.entries(group.tools).map(([toolId, label]) => {
115
- const enabled = sessionTools.includes(toolId)
76
+ {group.tools.map((tool) => {
77
+ const enabled = sessionTools.includes(tool.id)
116
78
  return (
117
- <label key={toolId} className="flex items-center gap-2.5 py-1.5 cursor-pointer">
79
+ <label key={tool.id} className="flex items-center gap-2.5 py-1.5 cursor-pointer">
118
80
  <div
119
- onClick={() => toggleTool(toolId)}
81
+ onClick={() => toggleTool(tool.id)}
120
82
  className={`w-8 h-[18px] rounded-full transition-all duration-200 relative cursor-pointer shrink-0
121
83
  ${enabled ? 'bg-[#6366F1]' : 'bg-white/[0.12]'}`}
122
84
  >
@@ -124,10 +86,7 @@ export function ChatToolToggles({ session }: Props) {
124
86
  ${enabled ? 'left-[16px]' : 'left-[2px]'}`} />
125
87
  </div>
126
88
  <span className={`text-[12px] ${enabled ? 'text-text-2' : 'text-text-3/70'}`}>
127
- {label}
128
- {TOOL_HINTS[toolId] && (
129
- <span className="ml-2 text-[10px] text-text-3/70 font-400">{TOOL_HINTS[toolId]}</span>
130
- )}
89
+ {tool.label}
131
90
  </span>
132
91
  </label>
133
92
  )
@@ -168,6 +168,115 @@ function heartbeatSummary(text: string): string {
168
168
  return clean.length > 180 ? `${clean.slice(0, 180)}...` : clean
169
169
  }
170
170
 
171
+ const IMAGE_ATTACH_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i
172
+ const PREVIEWABLE_ATTACH_RE = /\.(html?|svg)$/i
173
+ const FILE_TYPE_COLORS: Record<string, string> = {
174
+ html: 'text-orange-400', htm: 'text-orange-400', svg: 'text-emerald-400',
175
+ js: 'text-yellow-400', jsx: 'text-yellow-400', ts: 'text-blue-400', tsx: 'text-blue-400',
176
+ py: 'text-green-400', json: 'text-amber-300', css: 'text-purple-400', scss: 'text-pink-400',
177
+ md: 'text-text-2', txt: 'text-text-3', pdf: 'text-red-400',
178
+ }
179
+
180
+ function parseAttachmentUrl(filePath?: string, fileUrl?: string) {
181
+ const url = fileUrl || (filePath ? `/api/uploads/${filePath.split('/').pop()}` : '')
182
+ const rawName = filePath?.split('/').pop() || fileUrl?.split('/').pop() || 'file'
183
+ const filename = rawName.replace(/^[a-f0-9]+-/, '').split('?')[0]
184
+ return { url, filename }
185
+ }
186
+
187
+ function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: string; isUserMsg?: boolean }) {
188
+ const isImage = IMAGE_ATTACH_RE.test(filename)
189
+ if (isImage) {
190
+ return (
191
+ <img src={url} alt="Attached" className="max-w-[240px] rounded-[12px] mb-2 border border-white/10"
192
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
193
+ )
194
+ }
195
+
196
+ const ext = filename.split('.').pop()?.toLowerCase() || ''
197
+ const colorClass = FILE_TYPE_COLORS[ext] || 'text-text-3'
198
+ const isPreviewable = PREVIEWABLE_ATTACH_RE.test(filename)
199
+
200
+ // Solid bg so chip is readable on both user (purple) and assistant bubbles
201
+ const chipBg = isUserMsg
202
+ ? 'bg-[rgba(0,0,0,0.25)] border-white/[0.12]'
203
+ : 'bg-[rgba(255,255,255,0.04)] border-white/[0.08]'
204
+ const iconBg = isUserMsg ? 'bg-white/[0.12]' : 'bg-white/[0.05]'
205
+ const btnBg = isUserMsg
206
+ ? 'bg-white/[0.12] hover:bg-white/[0.18] text-white/80'
207
+ : 'bg-white/[0.06] hover:bg-white/[0.10] text-text-3'
208
+
209
+ return (
210
+ <div className={`flex items-center gap-3 px-4 py-2.5 mb-2 rounded-[12px] border ${chipBg}`}>
211
+ <div className={`flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 ${iconBg} ${colorClass}`}>
212
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
213
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
214
+ <polyline points="14 2 14 8 20 8" />
215
+ </svg>
216
+ </div>
217
+ <div className="flex flex-col flex-1 min-w-0">
218
+ <span className={`text-[13px] font-500 truncate ${isUserMsg ? 'text-white' : 'text-text'}`}>{filename}</span>
219
+ <span className={`text-[11px] uppercase tracking-wide ${isUserMsg ? 'text-white/50' : 'text-text-3/70'}`}>{ext || 'file'}</span>
220
+ </div>
221
+ {isPreviewable && (
222
+ <a href={url} target="_blank" rel="noopener noreferrer"
223
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${
224
+ isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
225
+ }`}
226
+ title="Preview in new tab">
227
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
228
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
229
+ <circle cx="12" cy="12" r="3" />
230
+ </svg>
231
+ Preview
232
+ </a>
233
+ )}
234
+ <a href={url} download={filename}
235
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${btnBg}`}>
236
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
237
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
238
+ <polyline points="7 10 12 15 17 10" />
239
+ <line x1="12" y1="15" x2="12" y2="3" />
240
+ </svg>
241
+ Download
242
+ </a>
243
+ </div>
244
+ )
245
+ }
246
+
247
+ function renderAttachments(message: Message) {
248
+ const isUser = message.role === 'user'
249
+ const seen = new Set<string>()
250
+ const chips: { url: string; filename: string }[] = []
251
+
252
+ // Primary attachment
253
+ if (message.imagePath || message.imageUrl) {
254
+ const primary = parseAttachmentUrl(message.imagePath, message.imageUrl)
255
+ if (primary.url) {
256
+ seen.add(primary.url)
257
+ chips.push(primary)
258
+ }
259
+ }
260
+
261
+ // Additional attached files
262
+ if (message.attachedFiles?.length) {
263
+ for (const fp of message.attachedFiles) {
264
+ const att = parseAttachmentUrl(fp)
265
+ if (att.url && !seen.has(att.url)) {
266
+ seen.add(att.url)
267
+ chips.push(att)
268
+ }
269
+ }
270
+ }
271
+
272
+ if (!chips.length) return null
273
+ return (
274
+ <div className="flex flex-col">
275
+ {chips.map((c) => <AttachmentChip key={c.url} url={c.url} filename={c.filename} isUserMsg={isUser} />)}
276
+ </div>
277
+ )
278
+ }
279
+
171
280
  interface Props {
172
281
  message: Message
173
282
  assistantName?: string
@@ -240,48 +349,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
240
349
 
241
350
  {/* Message bubble */}
242
351
  <div className={`max-w-[85%] md:max-w-[72%] ${isUser ? 'bubble-user px-5 py-3.5' : isHeartbeat ? 'bubble-ai px-4 py-3' : 'bubble-ai px-5 py-3.5'}`}>
243
- {(message.imagePath || message.imageUrl) && (() => {
244
- const url = message.imageUrl || `/api/uploads/${message.imagePath?.split('/').pop()}`
245
- const rawName = message.imagePath?.split('/').pop() || message.imageUrl?.split('/').pop() || 'file'
246
- const filename = rawName.replace(/^[a-f0-9]+-/, '').split('?')[0]
247
- const isImage = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i.test(filename)
248
- if (isImage) {
249
- return (
250
- <img src={url} alt="Attached" className="max-w-[240px] rounded-[12px] mb-3 border border-white/10"
251
- onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
252
- )
253
- }
254
- const isPreviewable = /\.(html?|svg)$/i.test(filename)
255
- return (
256
- <div className="flex items-center gap-3 px-4 py-3 mb-3 rounded-[12px] border border-white/10 bg-white/[0.03]">
257
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3 shrink-0">
258
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
259
- <polyline points="14 2 14 8 20 8" />
260
- </svg>
261
- <span className="text-[13px] text-text-2 font-500 truncate flex-1">{filename}</span>
262
- {isPreviewable && (
263
- <a href={url} target="_blank" rel="noopener noreferrer"
264
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-accent-soft hover:bg-accent-soft/80 text-accent-bright text-[11px] font-600 no-underline transition-colors shrink-0"
265
- title="Preview in new tab">
266
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
267
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
268
- <circle cx="12" cy="12" r="3" />
269
- </svg>
270
- Preview
271
- </a>
272
- )}
273
- <a href={url} download={filename}
274
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-white/[0.06] hover:bg-white/[0.10] text-text-3 text-[11px] font-600 no-underline transition-colors shrink-0">
275
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
276
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
277
- <polyline points="7 10 12 15 17 10" />
278
- <line x1="12" y1="15" x2="12" y2="3" />
279
- </svg>
280
- Download
281
- </a>
282
- </div>
283
- )
284
- })()}
352
+ {renderAttachments(message)}
285
353
 
286
354
  {isHeartbeat ? (
287
355
  <div className="flex flex-col gap-2">