@swarmclawai/swarmclaw 0.6.6 → 0.6.7
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.
- package/README.md +57 -27
- package/package.json +6 -1
- package/src/app/api/agents/[id]/clone/route.ts +40 -0
- package/src/app/api/agents/route.ts +39 -14
- package/src/app/api/chatrooms/[id]/chat/route.ts +17 -1
- package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
- package/src/app/api/chatrooms/[id]/route.ts +19 -1
- package/src/app/api/chatrooms/route.ts +12 -2
- package/src/app/api/connectors/[id]/health/route.ts +64 -0
- package/src/app/api/connectors/route.ts +17 -2
- package/src/app/api/knowledge/route.ts +6 -1
- package/src/app/api/openclaw/doctor/route.ts +17 -0
- package/src/app/api/sessions/[id]/chat/route.ts +5 -1
- package/src/app/api/sessions/route.ts +11 -2
- package/src/app/api/tasks/[id]/route.ts +18 -13
- package/src/app/api/tasks/route.ts +20 -1
- package/src/app/api/usage/route.ts +16 -7
- package/src/cli/index.js +5 -0
- package/src/cli/index.ts +223 -39
- package/src/components/agents/agent-card.tsx +37 -6
- package/src/components/agents/agent-chat-list.tsx +78 -2
- package/src/components/agents/agent-sheet.tsx +79 -0
- package/src/components/auth/setup-wizard.tsx +268 -353
- package/src/components/chat/chat-area.tsx +22 -7
- package/src/components/chat/message-bubble.tsx +14 -14
- package/src/components/chat/message-list.tsx +1 -1
- package/src/components/chatrooms/chatroom-message.tsx +164 -22
- package/src/components/chatrooms/chatroom-sheet.tsx +288 -3
- package/src/components/chatrooms/chatroom-view.tsx +62 -17
- package/src/components/connectors/connector-health.tsx +120 -0
- package/src/components/connectors/connector-sheet.tsx +9 -0
- package/src/components/home/home-view.tsx +23 -2
- package/src/components/input/chat-input.tsx +8 -1
- package/src/components/layout/app-layout.tsx +17 -1
- package/src/components/schedules/schedule-list.tsx +55 -9
- package/src/components/schedules/schedule-sheet.tsx +134 -23
- package/src/components/shared/command-palette.tsx +237 -0
- package/src/components/shared/connector-platform-icon.tsx +1 -0
- package/src/components/tasks/task-card.tsx +22 -2
- package/src/components/tasks/task-sheet.tsx +91 -16
- package/src/components/usage/metrics-dashboard.tsx +13 -25
- package/src/hooks/use-swipe.ts +49 -0
- package/src/lib/providers/anthropic.ts +16 -2
- package/src/lib/providers/claude-cli.ts +7 -1
- package/src/lib/providers/index.ts +7 -0
- package/src/lib/providers/ollama.ts +16 -2
- package/src/lib/providers/openai.ts +7 -2
- package/src/lib/providers/openclaw.ts +6 -1
- package/src/lib/providers/provider-defaults.ts +7 -0
- package/src/lib/schedule-templates.ts +115 -0
- package/src/lib/server/alert-dispatch.ts +64 -0
- package/src/lib/server/chat-execution.ts +41 -1
- package/src/lib/server/chatroom-helpers.ts +22 -1
- package/src/lib/server/chatroom-routing.ts +65 -0
- package/src/lib/server/connectors/discord.ts +3 -0
- package/src/lib/server/connectors/email.ts +267 -0
- package/src/lib/server/connectors/manager.ts +159 -3
- package/src/lib/server/connectors/openclaw.ts +3 -0
- package/src/lib/server/connectors/slack.ts +6 -0
- package/src/lib/server/connectors/telegram.ts +18 -0
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.ts +9 -0
- package/src/lib/server/cost.ts +70 -0
- package/src/lib/server/create-notification.ts +2 -0
- package/src/lib/server/daemon-state.ts +124 -0
- package/src/lib/server/dag-validation.ts +115 -0
- package/src/lib/server/memory-db.ts +12 -7
- package/src/lib/server/openclaw-doctor.ts +48 -0
- package/src/lib/server/queue.ts +12 -0
- package/src/lib/server/session-run-manager.ts +22 -1
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +22 -3
- package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
- package/src/lib/server/storage.ts +120 -6
- package/src/lib/setup-defaults.ts +277 -0
- package/src/lib/validation/schemas.ts +69 -0
- package/src/stores/use-app-store.ts +7 -3
- package/src/stores/use-chatroom-store.ts +52 -2
- package/src/types/index.ts +38 -1
- package/tsconfig.json +2 -1
|
@@ -6,9 +6,145 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
6
6
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
7
7
|
import { toast } from 'sonner'
|
|
8
8
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
9
|
-
import type { Agent } from '@/types'
|
|
9
|
+
import type { Agent, ChatroomRoutingRule } from '@/types'
|
|
10
10
|
import { CheckIcon } from '@/components/shared/check-icon'
|
|
11
11
|
|
|
12
|
+
function genRuleId(): string {
|
|
13
|
+
return `rule-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RuleFormState {
|
|
17
|
+
type: 'keyword' | 'capability'
|
|
18
|
+
pattern: string
|
|
19
|
+
keywords: string
|
|
20
|
+
agentId: string
|
|
21
|
+
priority: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const emptyRuleForm: RuleFormState = {
|
|
25
|
+
type: 'keyword',
|
|
26
|
+
pattern: '',
|
|
27
|
+
keywords: '',
|
|
28
|
+
agentId: '',
|
|
29
|
+
priority: 10,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function RoutingRuleForm({
|
|
33
|
+
rule,
|
|
34
|
+
memberAgents,
|
|
35
|
+
onSave,
|
|
36
|
+
onCancel,
|
|
37
|
+
}: {
|
|
38
|
+
rule: RuleFormState
|
|
39
|
+
memberAgents: Agent[]
|
|
40
|
+
onSave: (form: RuleFormState) => void
|
|
41
|
+
onCancel: () => void
|
|
42
|
+
}) {
|
|
43
|
+
const [form, setForm] = useState<RuleFormState>(rule)
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="p-3 rounded-[8px] bg-white/[0.04] border border-white/[0.08] space-y-3">
|
|
47
|
+
<div className="flex gap-2">
|
|
48
|
+
{(['keyword', 'capability'] as const).map((t) => (
|
|
49
|
+
<button
|
|
50
|
+
key={t}
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => setForm((f) => ({ ...f, type: t }))}
|
|
53
|
+
className={`flex-1 py-1.5 text-[11px] font-600 capitalize rounded-[6px] cursor-pointer transition-all ${
|
|
54
|
+
form.type === t
|
|
55
|
+
? 'bg-accent-soft text-accent-bright'
|
|
56
|
+
: 'bg-white/[0.04] text-text-3 hover:text-text-2'
|
|
57
|
+
}`}
|
|
58
|
+
>
|
|
59
|
+
{t}
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{form.type === 'keyword' && (
|
|
65
|
+
<>
|
|
66
|
+
<div>
|
|
67
|
+
<label className="block text-[11px] font-600 text-text-3 mb-1">Keywords (comma-separated)</label>
|
|
68
|
+
<input
|
|
69
|
+
type="text"
|
|
70
|
+
value={form.keywords}
|
|
71
|
+
onChange={(e) => setForm((f) => ({ ...f, keywords: e.target.value }))}
|
|
72
|
+
placeholder="e.g. deploy, devops, infrastructure"
|
|
73
|
+
className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<label className="block text-[11px] font-600 text-text-3 mb-1">Regex pattern (optional)</label>
|
|
78
|
+
<input
|
|
79
|
+
type="text"
|
|
80
|
+
value={form.pattern}
|
|
81
|
+
onChange={(e) => setForm((f) => ({ ...f, pattern: e.target.value }))}
|
|
82
|
+
placeholder="e.g. deploy|release|ship"
|
|
83
|
+
className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{form.type === 'capability' && (
|
|
90
|
+
<div>
|
|
91
|
+
<label className="block text-[11px] font-600 text-text-3 mb-1">Capability pattern</label>
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
value={form.pattern}
|
|
95
|
+
onChange={(e) => setForm((f) => ({ ...f, pattern: e.target.value }))}
|
|
96
|
+
placeholder="e.g. frontend, research, devops"
|
|
97
|
+
className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<div className="flex gap-2">
|
|
103
|
+
<div className="flex-1">
|
|
104
|
+
<label className="block text-[11px] font-600 text-text-3 mb-1">Route to agent</label>
|
|
105
|
+
<select
|
|
106
|
+
value={form.agentId}
|
|
107
|
+
onChange={(e) => setForm((f) => ({ ...f, agentId: e.target.value }))}
|
|
108
|
+
className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text focus:outline-none focus:border-accent-bright/40"
|
|
109
|
+
>
|
|
110
|
+
<option value="">Select agent...</option>
|
|
111
|
+
{memberAgents.map((a) => (
|
|
112
|
+
<option key={a.id} value={a.id}>{a.name}</option>
|
|
113
|
+
))}
|
|
114
|
+
</select>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="w-20">
|
|
117
|
+
<label className="block text-[11px] font-600 text-text-3 mb-1">Priority</label>
|
|
118
|
+
<input
|
|
119
|
+
type="number"
|
|
120
|
+
value={form.priority}
|
|
121
|
+
onChange={(e) => setForm((f) => ({ ...f, priority: parseInt(e.target.value, 10) || 0 }))}
|
|
122
|
+
className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text focus:outline-none focus:border-accent-bright/40"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="flex gap-2 justify-end">
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={onCancel}
|
|
131
|
+
className="px-3 py-1.5 text-[11px] font-600 text-text-3 hover:text-text-2 cursor-pointer"
|
|
132
|
+
>
|
|
133
|
+
Cancel
|
|
134
|
+
</button>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={() => onSave(form)}
|
|
138
|
+
disabled={!form.agentId || (form.type === 'keyword' && !form.keywords.trim() && !form.pattern.trim()) || (form.type === 'capability' && !form.pattern.trim())}
|
|
139
|
+
className="px-3 py-1.5 text-[11px] font-600 bg-accent-bright text-white rounded-[6px] hover:bg-accent-bright/90 disabled:opacity-50 cursor-pointer"
|
|
140
|
+
>
|
|
141
|
+
Save Rule
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
12
148
|
export function ChatroomSheet() {
|
|
13
149
|
const open = useChatroomStore((s) => s.chatroomSheetOpen)
|
|
14
150
|
const editingId = useChatroomStore((s) => s.editingChatroomId)
|
|
@@ -25,7 +161,10 @@ export function ChatroomSheet() {
|
|
|
25
161
|
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([])
|
|
26
162
|
const [chatMode, setChatMode] = useState<'sequential' | 'parallel'>('sequential')
|
|
27
163
|
const [autoAddress, setAutoAddress] = useState(false)
|
|
164
|
+
const [routingRules, setRoutingRules] = useState<ChatroomRoutingRule[]>([])
|
|
28
165
|
const [saving, setSaving] = useState(false)
|
|
166
|
+
const [addingRule, setAddingRule] = useState(false)
|
|
167
|
+
const [editingRuleId, setEditingRuleId] = useState<string | null>(null)
|
|
29
168
|
|
|
30
169
|
const editing = editingId ? chatrooms[editingId] : null
|
|
31
170
|
|
|
@@ -36,24 +175,36 @@ export function ChatroomSheet() {
|
|
|
36
175
|
setSelectedAgentIds([...editing.agentIds])
|
|
37
176
|
setChatMode(editing.chatMode || 'sequential')
|
|
38
177
|
setAutoAddress(editing.autoAddress || false)
|
|
178
|
+
setRoutingRules([...(editing.routingRules || [])])
|
|
39
179
|
} else {
|
|
40
180
|
setName('')
|
|
41
181
|
setDescription('')
|
|
42
182
|
setSelectedAgentIds([])
|
|
43
183
|
setChatMode('sequential')
|
|
44
184
|
setAutoAddress(false)
|
|
185
|
+
setRoutingRules([])
|
|
45
186
|
}
|
|
187
|
+
setAddingRule(false)
|
|
188
|
+
setEditingRuleId(null)
|
|
46
189
|
}, [editing, open])
|
|
47
190
|
|
|
48
191
|
const handleSave = async () => {
|
|
49
192
|
if (!name.trim() || saving) return
|
|
50
193
|
setSaving(true)
|
|
51
194
|
try {
|
|
195
|
+
const payload = {
|
|
196
|
+
name,
|
|
197
|
+
description,
|
|
198
|
+
agentIds: selectedAgentIds,
|
|
199
|
+
chatMode,
|
|
200
|
+
autoAddress,
|
|
201
|
+
routingRules: routingRules.length > 0 ? routingRules : undefined,
|
|
202
|
+
}
|
|
52
203
|
if (editing) {
|
|
53
|
-
await updateChatroom(editing.id,
|
|
204
|
+
await updateChatroom(editing.id, payload)
|
|
54
205
|
toast.success('Chatroom saved')
|
|
55
206
|
} else {
|
|
56
|
-
const chatroom = await createChatroom(
|
|
207
|
+
const chatroom = await createChatroom(payload)
|
|
57
208
|
setCurrentChatroom(chatroom.id)
|
|
58
209
|
toast.success('Chatroom created')
|
|
59
210
|
}
|
|
@@ -81,10 +232,53 @@ export function ChatroomSheet() {
|
|
|
81
232
|
)
|
|
82
233
|
}
|
|
83
234
|
|
|
235
|
+
const handleAddRule = (form: RuleFormState) => {
|
|
236
|
+
const rule: ChatroomRoutingRule = {
|
|
237
|
+
id: genRuleId(),
|
|
238
|
+
type: form.type,
|
|
239
|
+
agentId: form.agentId,
|
|
240
|
+
priority: form.priority,
|
|
241
|
+
...(form.pattern.trim() ? { pattern: form.pattern.trim() } : {}),
|
|
242
|
+
...(form.type === 'keyword' && form.keywords.trim()
|
|
243
|
+
? { keywords: form.keywords.split(',').map((k) => k.trim()).filter(Boolean) }
|
|
244
|
+
: {}),
|
|
245
|
+
}
|
|
246
|
+
setRoutingRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority))
|
|
247
|
+
setAddingRule(false)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const handleEditRule = (form: RuleFormState) => {
|
|
251
|
+
setRoutingRules((prev) =>
|
|
252
|
+
prev.map((r) =>
|
|
253
|
+
r.id === editingRuleId
|
|
254
|
+
? {
|
|
255
|
+
...r,
|
|
256
|
+
type: form.type,
|
|
257
|
+
agentId: form.agentId,
|
|
258
|
+
priority: form.priority,
|
|
259
|
+
pattern: form.pattern.trim() || undefined,
|
|
260
|
+
keywords:
|
|
261
|
+
form.type === 'keyword' && form.keywords.trim()
|
|
262
|
+
? form.keywords.split(',').map((k) => k.trim()).filter(Boolean)
|
|
263
|
+
: undefined,
|
|
264
|
+
}
|
|
265
|
+
: r,
|
|
266
|
+
).sort((a, b) => a.priority - b.priority),
|
|
267
|
+
)
|
|
268
|
+
setEditingRuleId(null)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const removeRule = (ruleId: string) => {
|
|
272
|
+
setRoutingRules((prev) => prev.filter((r) => r.id !== ruleId))
|
|
273
|
+
}
|
|
274
|
+
|
|
84
275
|
const agentList = Object.values(agents).filter(
|
|
85
276
|
(a: Agent) => !a.trashedAt
|
|
86
277
|
) as Agent[]
|
|
87
278
|
|
|
279
|
+
const memberAgents = agentList.filter((a) => selectedAgentIds.includes(a.id))
|
|
280
|
+
const sortedRules = [...routingRules].sort((a, b) => a.priority - b.priority)
|
|
281
|
+
|
|
88
282
|
return (
|
|
89
283
|
<BottomSheet open={open} onClose={() => setChatroomSheetOpen(false)}>
|
|
90
284
|
<div className="p-6 max-w-[560px] mx-auto">
|
|
@@ -189,6 +383,97 @@ export function ChatroomSheet() {
|
|
|
189
383
|
)}
|
|
190
384
|
</div>
|
|
191
385
|
</div>
|
|
386
|
+
|
|
387
|
+
{/* Routing Rules */}
|
|
388
|
+
<div>
|
|
389
|
+
<label className="block text-[12px] font-600 text-text-2 mb-1.5">
|
|
390
|
+
Routing Rules ({sortedRules.length})
|
|
391
|
+
</label>
|
|
392
|
+
<p className="text-[11px] text-text-3 mb-2">
|
|
393
|
+
Route messages to specific agents based on keywords or capabilities. Evaluated before auto-address.
|
|
394
|
+
</p>
|
|
395
|
+
|
|
396
|
+
{sortedRules.length > 0 && (
|
|
397
|
+
<div className="space-y-2 mb-2">
|
|
398
|
+
{sortedRules.map((rule) => {
|
|
399
|
+
const agent = agents[rule.agentId]
|
|
400
|
+
if (editingRuleId === rule.id) {
|
|
401
|
+
return (
|
|
402
|
+
<RoutingRuleForm
|
|
403
|
+
key={rule.id}
|
|
404
|
+
rule={{
|
|
405
|
+
type: rule.type,
|
|
406
|
+
pattern: rule.pattern || '',
|
|
407
|
+
keywords: rule.keywords?.join(', ') || '',
|
|
408
|
+
agentId: rule.agentId,
|
|
409
|
+
priority: rule.priority,
|
|
410
|
+
}}
|
|
411
|
+
memberAgents={memberAgents}
|
|
412
|
+
onSave={handleEditRule}
|
|
413
|
+
onCancel={() => setEditingRuleId(null)}
|
|
414
|
+
/>
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
return (
|
|
418
|
+
<div
|
|
419
|
+
key={rule.id}
|
|
420
|
+
className="flex items-center gap-2 px-3 py-2 rounded-[8px] bg-white/[0.04] border border-white/[0.08]"
|
|
421
|
+
>
|
|
422
|
+
<span className="text-[10px] font-700 text-text-3 bg-white/[0.06] px-1.5 py-0.5 rounded">
|
|
423
|
+
P{rule.priority}
|
|
424
|
+
</span>
|
|
425
|
+
<span className="text-[10px] font-600 text-accent-bright/70 uppercase">
|
|
426
|
+
{rule.type}
|
|
427
|
+
</span>
|
|
428
|
+
<span className="text-[12px] text-text-2 flex-1 truncate">
|
|
429
|
+
{rule.type === 'keyword'
|
|
430
|
+
? (rule.keywords?.join(', ') || rule.pattern || '(no match)')
|
|
431
|
+
: (rule.pattern || '(no pattern)')}
|
|
432
|
+
</span>
|
|
433
|
+
<span className="text-[11px] text-text-3 truncate max-w-[100px]">
|
|
434
|
+
{agent?.name || 'Unknown'}
|
|
435
|
+
</span>
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
onClick={() => setEditingRuleId(rule.id)}
|
|
439
|
+
className="text-[11px] text-text-3 hover:text-text-2 cursor-pointer px-1"
|
|
440
|
+
>
|
|
441
|
+
Edit
|
|
442
|
+
</button>
|
|
443
|
+
<button
|
|
444
|
+
type="button"
|
|
445
|
+
onClick={() => removeRule(rule.id)}
|
|
446
|
+
className="text-[11px] text-red-400 hover:text-red-300 cursor-pointer px-1"
|
|
447
|
+
>
|
|
448
|
+
Remove
|
|
449
|
+
</button>
|
|
450
|
+
</div>
|
|
451
|
+
)
|
|
452
|
+
})}
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
|
|
456
|
+
{addingRule ? (
|
|
457
|
+
<RoutingRuleForm
|
|
458
|
+
rule={emptyRuleForm}
|
|
459
|
+
memberAgents={memberAgents}
|
|
460
|
+
onSave={handleAddRule}
|
|
461
|
+
onCancel={() => setAddingRule(false)}
|
|
462
|
+
/>
|
|
463
|
+
) : (
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={() => setAddingRule(true)}
|
|
467
|
+
disabled={memberAgents.length === 0}
|
|
468
|
+
className="w-full py-2 rounded-[8px] border border-dashed border-white/[0.12] text-[12px] font-600 text-text-3 hover:text-text-2 hover:border-white/[0.2] cursor-pointer transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
|
469
|
+
>
|
|
470
|
+
+ Add Rule
|
|
471
|
+
</button>
|
|
472
|
+
)}
|
|
473
|
+
{memberAgents.length === 0 && (
|
|
474
|
+
<p className="text-[11px] text-text-3 mt-1">Add members first to create routing rules.</p>
|
|
475
|
+
)}
|
|
476
|
+
</div>
|
|
192
477
|
</div>
|
|
193
478
|
|
|
194
479
|
<div className="flex items-center gap-3 mt-6">
|
|
@@ -10,13 +10,35 @@ import { ChatroomTypingBar } from './chatroom-typing-bar'
|
|
|
10
10
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
11
11
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
|
12
12
|
import { HeartbeatMoment, ActivityMoment, isNotableTool } from '@/components/chat/activity-moment'
|
|
13
|
-
import type { Chatroom, ChatroomMessage, Agent } from '@/types'
|
|
13
|
+
import type { Chatroom, ChatroomMessage, ChatroomMember, Agent } from '@/types'
|
|
14
14
|
|
|
15
15
|
function navigateToAgent(agentId: string) {
|
|
16
16
|
useAppStore.getState().setActiveView('agents')
|
|
17
17
|
useAppStore.getState().setCurrentAgent(agentId)
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function getRoleBadge(role: string) {
|
|
21
|
+
if (role === 'admin') return { label: 'Admin', className: 'bg-purple-500/20 text-purple-400 border-purple-500/30' }
|
|
22
|
+
if (role === 'moderator') return { label: 'Mod', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' }
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getMemberFromChatroom(chatroom: Chatroom, agentId: string): ChatroomMember | undefined {
|
|
27
|
+
if (chatroom.members?.length) return chatroom.members.find((m) => m.agentId === agentId)
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getMemberRole(chatroom: Chatroom, agentId: string): string {
|
|
32
|
+
const member = getMemberFromChatroom(chatroom, agentId)
|
|
33
|
+
return member?.role || 'member'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isAgentMuted(chatroom: Chatroom, agentId: string): boolean {
|
|
37
|
+
const member = getMemberFromChatroom(chatroom, agentId)
|
|
38
|
+
if (!member?.mutedUntil) return false
|
|
39
|
+
return new Date(member.mutedUntil).getTime() > Date.now()
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
|
|
21
43
|
|
|
22
44
|
/** Subscribe to a single agent heartbeat topic — one hook call per agent */
|
|
@@ -65,6 +87,10 @@ export function ChatroomView() {
|
|
|
65
87
|
const loadChatrooms = useChatroomStore((s) => s.loadChatrooms)
|
|
66
88
|
const setChatroomSheetOpen = useChatroomStore((s) => s.setChatroomSheetOpen)
|
|
67
89
|
const setEditingChatroomId = useChatroomStore((s) => s.setEditingChatroomId)
|
|
90
|
+
const deleteMessage = useChatroomStore((s) => s.deleteMessage)
|
|
91
|
+
const muteAgent = useChatroomStore((s) => s.muteAgent)
|
|
92
|
+
const unmuteAgent = useChatroomStore((s) => s.unmuteAgent)
|
|
93
|
+
const setMemberRole = useChatroomStore((s) => s.setMemberRole)
|
|
68
94
|
const agents = useAppStore((s) => s.agents) as Record<string, Agent>
|
|
69
95
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
70
96
|
const [pinsExpanded, setPinsExpanded] = useState(false)
|
|
@@ -183,23 +209,37 @@ export function ChatroomView() {
|
|
|
183
209
|
{chatroom.description ? ` · ${chatroom.description}` : ''}
|
|
184
210
|
</p>
|
|
185
211
|
</div>
|
|
186
|
-
{/* Member avatars */}
|
|
212
|
+
{/* Member avatars with role badges */}
|
|
187
213
|
<div className="flex -space-x-1.5 shrink-0">
|
|
188
|
-
{memberAgents.slice(0, 5).map((agent) =>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
>
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
214
|
+
{memberAgents.slice(0, 5).map((agent) => {
|
|
215
|
+
const role = getMemberRole(chatroom, agent.id)
|
|
216
|
+
const badge = getRoleBadge(role)
|
|
217
|
+
const muted = isAgentMuted(chatroom, agent.id)
|
|
218
|
+
return (
|
|
219
|
+
<Tooltip key={agent.id}>
|
|
220
|
+
<TooltipTrigger asChild>
|
|
221
|
+
<button
|
|
222
|
+
onClick={() => navigateToAgent(agent.id)}
|
|
223
|
+
className={`relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0 ${muted ? 'opacity-40' : ''}`}
|
|
224
|
+
>
|
|
225
|
+
<AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
|
|
226
|
+
{badge && (
|
|
227
|
+
<span className={`absolute -bottom-1 -right-1 text-[7px] font-700 px-0.5 rounded border ${badge.className}`}>
|
|
228
|
+
{badge.label[0]}
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
</button>
|
|
232
|
+
</TooltipTrigger>
|
|
233
|
+
<TooltipContent side="bottom" sideOffset={6}>
|
|
234
|
+
<div className="flex items-center gap-1.5">
|
|
235
|
+
<span>{agent.name}</span>
|
|
236
|
+
{badge && <span className={`text-[9px] font-600 px-1 py-0.5 rounded border ${badge.className}`}>{badge.label}</span>}
|
|
237
|
+
{muted && <span className="text-[9px] text-red-400">Muted</span>}
|
|
238
|
+
</div>
|
|
239
|
+
</TooltipContent>
|
|
240
|
+
</Tooltip>
|
|
241
|
+
)
|
|
242
|
+
})}
|
|
203
243
|
{memberAgents.length > 5 && (
|
|
204
244
|
<div className="w-[22px] h-[22px] rounded-full bg-white/[0.08] flex items-center justify-center text-[9px] text-text-3">
|
|
205
245
|
+{memberAgents.length - 5}
|
|
@@ -320,6 +360,11 @@ export function ChatroomView() {
|
|
|
320
360
|
onReply={(m: ChatroomMessage) => setReplyingTo(m)}
|
|
321
361
|
onTogglePin={togglePin}
|
|
322
362
|
onTransfer={handleTransfer}
|
|
363
|
+
onDeleteMessage={(messageId, targetAgentId) => deleteMessage(messageId, targetAgentId)}
|
|
364
|
+
onMuteAgent={(agentId) => muteAgent(agentId)}
|
|
365
|
+
onUnmuteAgent={(agentId) => unmuteAgent(agentId)}
|
|
366
|
+
onSetRole={(agentId, role) => setMemberRole(agentId, role)}
|
|
367
|
+
chatroom={chatroom}
|
|
323
368
|
pinnedMessageIds={pinnedIds}
|
|
324
369
|
streamingAgentIds={streamingAgentIds}
|
|
325
370
|
messages={chatroom.messages}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { api } from '@/lib/api-client'
|
|
5
|
+
import type { ConnectorHealthEvent, ConnectorHealthEventType } from '@/types'
|
|
6
|
+
|
|
7
|
+
interface HealthResponse {
|
|
8
|
+
events: ConnectorHealthEvent[]
|
|
9
|
+
uptimePercent: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const EVENT_CONFIG: Record<ConnectorHealthEventType, { color: string; label: string }> = {
|
|
13
|
+
started: { color: 'bg-green-400', label: 'Started' },
|
|
14
|
+
reconnected: { color: 'bg-green-400', label: 'Reconnected' },
|
|
15
|
+
stopped: { color: 'bg-white/30', label: 'Stopped' },
|
|
16
|
+
error: { color: 'bg-red-400', label: 'Error' },
|
|
17
|
+
disconnected: { color: 'bg-amber-400', label: 'Disconnected' },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatTimestamp(ts: string): string {
|
|
21
|
+
const d = new Date(ts)
|
|
22
|
+
const now = new Date()
|
|
23
|
+
const diffMs = now.getTime() - d.getTime()
|
|
24
|
+
const diffMin = Math.floor(diffMs / 60_000)
|
|
25
|
+
const diffHr = Math.floor(diffMs / 3_600_000)
|
|
26
|
+
const diffDay = Math.floor(diffMs / 86_400_000)
|
|
27
|
+
|
|
28
|
+
if (diffMin < 1) return 'just now'
|
|
29
|
+
if (diffMin < 60) return `${diffMin}m ago`
|
|
30
|
+
if (diffHr < 24) return `${diffHr}h ago`
|
|
31
|
+
if (diffDay < 7) return `${diffDay}d ago`
|
|
32
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function uptimeBadgeColor(pct: number): string {
|
|
36
|
+
if (pct >= 99) return 'bg-green-500/15 text-green-400 border-green-500/20'
|
|
37
|
+
if (pct >= 95) return 'bg-amber-500/15 text-amber-400 border-amber-500/20'
|
|
38
|
+
return 'bg-red-500/15 text-red-400 border-red-500/20'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ConnectorHealth({ connectorId }: { connectorId: string }) {
|
|
42
|
+
const [data, setData] = useState<HealthResponse | null>(null)
|
|
43
|
+
const [loading, setLoading] = useState(true)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let cancelled = false
|
|
47
|
+
async function load() {
|
|
48
|
+
setLoading(true)
|
|
49
|
+
try {
|
|
50
|
+
const resp = await api<HealthResponse>('GET', `/connectors/${connectorId}/health`)
|
|
51
|
+
if (!cancelled) setData(resp)
|
|
52
|
+
} catch {
|
|
53
|
+
// ignore fetch errors
|
|
54
|
+
} finally {
|
|
55
|
+
if (!cancelled) setLoading(false)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
load()
|
|
59
|
+
return () => { cancelled = true }
|
|
60
|
+
}, [connectorId])
|
|
61
|
+
|
|
62
|
+
if (loading) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="p-4 rounded-[14px] border border-white/[0.06] bg-white/[0.01]">
|
|
65
|
+
<div className="text-[13px] text-text-3 animate-pulse">Loading health data...</div>
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!data || data.events.length === 0) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="p-4 rounded-[14px] border border-white/[0.06] bg-white/[0.01]">
|
|
73
|
+
<div className="text-[13px] text-text-3">No health events recorded yet.</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Show most recent events first (up to 50)
|
|
79
|
+
const recentEvents = [...data.events].reverse().slice(0, 50)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="p-4 rounded-[14px] border border-white/[0.06] bg-white/[0.01] space-y-4">
|
|
83
|
+
{/* Uptime badge */}
|
|
84
|
+
<div className="flex items-center justify-between">
|
|
85
|
+
<div className="text-[13px] font-600 text-text-2">Health Timeline</div>
|
|
86
|
+
<span className={`px-3 py-1 rounded-[8px] text-[12px] font-600 border ${uptimeBadgeColor(data.uptimePercent)}`}>
|
|
87
|
+
{data.uptimePercent}% uptime
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Timeline */}
|
|
92
|
+
<div className="relative pl-5">
|
|
93
|
+
{/* Vertical line */}
|
|
94
|
+
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-white/[0.08]" />
|
|
95
|
+
|
|
96
|
+
<div className="space-y-3">
|
|
97
|
+
{recentEvents.map((ev) => {
|
|
98
|
+
const cfg = EVENT_CONFIG[ev.event] ?? { color: 'bg-white/30', label: ev.event }
|
|
99
|
+
return (
|
|
100
|
+
<div key={ev.id} className="relative flex items-start gap-3">
|
|
101
|
+
{/* Dot */}
|
|
102
|
+
<div className={`absolute left-[-13px] top-[6px] w-[10px] h-[10px] rounded-full ${cfg.color} ring-2 ring-surface shrink-0`} />
|
|
103
|
+
{/* Content */}
|
|
104
|
+
<div className="min-w-0 flex-1">
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<span className="text-[13px] font-600 text-text-2">{cfg.label}</span>
|
|
107
|
+
<span className="text-[11px] text-text-3">{formatTimestamp(ev.timestamp)}</span>
|
|
108
|
+
</div>
|
|
109
|
+
{ev.message && (
|
|
110
|
+
<p className="text-[12px] text-text-3/70 mt-0.5 leading-[1.4] break-words">{ev.message}</p>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -13,6 +13,7 @@ import { ChatroomPickerList } from '@/components/shared/chatroom-picker-list'
|
|
|
13
13
|
import { SheetFooter } from '@/components/shared/sheet-footer'
|
|
14
14
|
import { SectionLabel } from '@/components/shared/section-label'
|
|
15
15
|
import { useChatroomStore } from '@/stores/use-chatroom-store'
|
|
16
|
+
import { ConnectorHealth } from '@/components/connectors/connector-health'
|
|
16
17
|
|
|
17
18
|
/** Auto-detect URLs in text and make them clickable links that open in a new tab */
|
|
18
19
|
function linkify(text: string) {
|
|
@@ -655,6 +656,7 @@ export function ConnectorSheet() {
|
|
|
655
656
|
<span key={i} className="flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] bg-accent-soft/50 border border-accent-bright/20 text-[12px] font-mono text-accent-bright">
|
|
656
657
|
{tag}
|
|
657
658
|
<button
|
|
659
|
+
aria-label={`Remove ${tag}`}
|
|
658
660
|
onClick={() => {
|
|
659
661
|
const next = tags.filter((_, j) => j !== i).join(',')
|
|
660
662
|
setConfig({ ...config, [field.key]: next })
|
|
@@ -860,6 +862,13 @@ export function ConnectorSheet() {
|
|
|
860
862
|
</div>
|
|
861
863
|
)}
|
|
862
864
|
|
|
865
|
+
{/* Health timeline (existing connectors only) */}
|
|
866
|
+
{editing && (
|
|
867
|
+
<div className="mb-6">
|
|
868
|
+
<ConnectorHealth connectorId={editing.id} />
|
|
869
|
+
</div>
|
|
870
|
+
)}
|
|
871
|
+
|
|
863
872
|
{/* Actions */}
|
|
864
873
|
<SheetFooter
|
|
865
874
|
onCancel={() => { setOpen(false); setEditingId(null) }}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
|
|
4
5
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
6
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
6
7
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
@@ -85,6 +86,7 @@ export function HomeView() {
|
|
|
85
86
|
const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen)
|
|
86
87
|
const setMessages = useChatStore((s) => s.setMessages)
|
|
87
88
|
const [todayCost, setTodayCost] = useState(0)
|
|
89
|
+
const [costTrend, setCostTrend] = useState<{ cost: number }[]>([])
|
|
88
90
|
|
|
89
91
|
const allAgents = Object.values(agents).filter((a) => !a.trashedAt)
|
|
90
92
|
const pinnedAgents = allAgents.filter((a) => a.pinned)
|
|
@@ -144,10 +146,11 @@ export function HomeView() {
|
|
|
144
146
|
void loadSchedules()
|
|
145
147
|
void loadNotifications()
|
|
146
148
|
void loadConnectors()
|
|
147
|
-
api<{ records: Array<{ estimatedCost: number }> }>('GET', '/usage?range=
|
|
149
|
+
api<{ records: Array<{ estimatedCost: number }>; timeSeries: Array<{ cost: number }> }>('GET', '/usage?range=7d')
|
|
148
150
|
.then((data) => {
|
|
149
151
|
const total = (data.records || []).reduce((s, r) => s + (r.estimatedCost || 0), 0)
|
|
150
152
|
setTodayCost(total)
|
|
153
|
+
setCostTrend((data.timeSeries || []).map((pt) => ({ cost: pt.cost })))
|
|
151
154
|
})
|
|
152
155
|
.catch(() => {})
|
|
153
156
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -196,13 +199,31 @@ export function HomeView() {
|
|
|
196
199
|
</div>
|
|
197
200
|
|
|
198
201
|
{/* Quick Stats */}
|
|
199
|
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-
|
|
202
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
200
203
|
<StatCard label="Agents" value={String(agentCount)} />
|
|
201
204
|
<StatCard label="Active Tasks" value={String(activeTaskCount)} accent={activeTaskCount > 0} />
|
|
202
205
|
<StatCard label="Today's Cost" value={`$${todayCost.toFixed(2)}`} />
|
|
203
206
|
<StatCard label="Connectors" value={`${activeConnectorCount}/${allConnectors.length}`} accent={activeConnectorCount > 0} />
|
|
204
207
|
</div>
|
|
205
208
|
|
|
209
|
+
{/* Cost trend sparkline */}
|
|
210
|
+
{costTrend.length > 1 && (
|
|
211
|
+
<div className="mb-10 px-1">
|
|
212
|
+
<p className="text-[10px] text-text-3/50 uppercase tracking-wider mb-1">7-day cost trend</p>
|
|
213
|
+
<ResponsiveContainer width="100%" height={60}>
|
|
214
|
+
<AreaChart data={costTrend} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
|
215
|
+
<defs>
|
|
216
|
+
<linearGradient id="costGrad" x1="0" y1="0" x2="0" y2="1">
|
|
217
|
+
<stop offset="0%" stopColor="#818CF8" stopOpacity={0.3} />
|
|
218
|
+
<stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
|
|
219
|
+
</linearGradient>
|
|
220
|
+
</defs>
|
|
221
|
+
<Area type="monotone" dataKey="cost" stroke="#818CF8" strokeWidth={1.5} fill="url(#costGrad)" dot={false} />
|
|
222
|
+
</AreaChart>
|
|
223
|
+
</ResponsiveContainer>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
|
|
206
227
|
{/* Notifications banner */}
|
|
207
228
|
{unreadNotifications.length > 0 && (
|
|
208
229
|
<section className="mb-8">
|