@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
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<img src="https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/public/branding/swarmclaw-org-avatar.png" alt="SwarmClaw lobster logo" width="120" />
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The orchestration dashboard for OpenClaw. Manage a swarm of OpenClaws + 14 other AI providers, orchestrate LangGraph workflows, schedule tasks, and bridge agents to 10+ chat platforms — all from one self-hosted UI.
|
|
12
12
|
|
|
13
13
|
Inspired by [OpenClaw](https://github.com/openclaw).
|
|
14
14
|
|
|
@@ -18,6 +18,44 @@ Inspired by [OpenClaw](https://github.com/openclaw).
|
|
|
18
18
|

|
|
19
19
|

|
|
20
20
|
|
|
21
|
+
## OpenClaw Integration
|
|
22
|
+
|
|
23
|
+
SwarmClaw was built for OpenClaw users who outgrew a single agent. Connect each SwarmClaw agent to a different OpenClaw gateway (one local, several remote) and manage the whole swarm from one UI.
|
|
24
|
+
|
|
25
|
+
SwarmClaw includes the `openclaw` CLI as a bundled dependency, so there is no separate OpenClaw CLI install step.
|
|
26
|
+
|
|
27
|
+
The OpenClaw Control Plane in SwarmClaw adds:
|
|
28
|
+
- Reload mode switching (`hot`, `hybrid`, `full`)
|
|
29
|
+
- Config issue detection and guided repair
|
|
30
|
+
- Remote history sync
|
|
31
|
+
- Live execution approval handling
|
|
32
|
+
|
|
33
|
+
The Agent Inspector Panel lets you edit OpenClaw files (`SOUL.md`, `IDENTITY.md`, `USER.md`), tune personality/system behavior, and manage OpenClaw-compatible skills. SwarmClaw also supports importing OpenClaw `SKILL.md` files from URL.
|
|
34
|
+
|
|
35
|
+
To connect an agent to an OpenClaw gateway:
|
|
36
|
+
|
|
37
|
+
1. Create or edit an agent
|
|
38
|
+
2. Toggle **OpenClaw Gateway** ON
|
|
39
|
+
3. Enter the gateway URL (e.g. `http://192.168.1.50:18789` or `https://my-vps:18789`)
|
|
40
|
+
4. Add a gateway token if authentication is enabled on the remote gateway
|
|
41
|
+
5. Click **Connect** — approve the device in your gateway's dashboard if prompted, then **Retry Connection**
|
|
42
|
+
|
|
43
|
+
Each agent can point to a **different** OpenClaw gateway — one local, several remote. This is how you manage a **swarm of OpenClaws** from a single dashboard.
|
|
44
|
+
|
|
45
|
+
URLs without a protocol are auto-prefixed with `http://`. For remote gateways with TLS, use `https://` explicitly.
|
|
46
|
+
|
|
47
|
+
## SwarmClaw ClawHub Skill
|
|
48
|
+
|
|
49
|
+
Use the `swarmclaw` ClawHub skill when you want an OpenClaw agent to operate your SwarmClaw control plane directly from chat: list agents, dispatch tasks, check sessions, run diagnostics, and coordinate multi-agent work.
|
|
50
|
+
|
|
51
|
+
Install it from ClawHub:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
clawhub install swarmclaw
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Skill source and runbook: [`swarmclaw/SKILL.md`](swarmclaw/SKILL.md).
|
|
58
|
+
|
|
21
59
|
- Always use the access key authentication (generated on first run)
|
|
22
60
|
- Never expose port 3456 without a reverse proxy + TLS
|
|
23
61
|
- Review agent system prompts before giving them shell or browser tools
|
|
@@ -47,7 +85,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
47
85
|
```
|
|
48
86
|
|
|
49
87
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
50
|
-
To pin a version: `SWARMCLAW_VERSION=v0.6.
|
|
88
|
+
To pin a version: `SWARMCLAW_VERSION=v0.6.6 curl ... | bash`
|
|
51
89
|
|
|
52
90
|
Or run locally from the repo (friendly for non-technical users):
|
|
53
91
|
|
|
@@ -80,11 +118,16 @@ You can complete first-time setup from terminal:
|
|
|
80
118
|
# Start the app (if not already running)
|
|
81
119
|
npm run dev
|
|
82
120
|
|
|
83
|
-
# In another terminal, run setup
|
|
121
|
+
# In another terminal, run interactive setup (walks you through provider selection)
|
|
122
|
+
node ./bin/swarmclaw.js setup init
|
|
123
|
+
|
|
124
|
+
# Or pass flags directly for non-interactive setup
|
|
84
125
|
node ./bin/swarmclaw.js setup init --provider openai --api-key "$OPENAI_API_KEY"
|
|
85
126
|
```
|
|
86
127
|
|
|
87
128
|
Notes:
|
|
129
|
+
- When run with no flags in a TTY, `setup init` enters interactive mode — pick providers, enter keys, name agents, and add multiple providers in one session.
|
|
130
|
+
- Use `--no-interactive` to force flag-only mode.
|
|
88
131
|
- On a fresh instance, `setup init` can auto-discover and claim the first-run access key from `/api/auth`.
|
|
89
132
|
- For existing installs, pass `--key <ACCESS_KEY>` (or set `SWARMCLAW_ACCESS_KEY`).
|
|
90
133
|
- `setup init` performs provider validation, stores credentials, creates a starter agent, and marks setup complete.
|
|
@@ -94,22 +137,22 @@ Notes:
|
|
|
94
137
|
|
|
95
138
|
After login, SwarmClaw opens a guided wizard designed for non-technical users:
|
|
96
139
|
|
|
97
|
-
1. Choose a provider
|
|
98
|
-
2.
|
|
99
|
-
3.
|
|
100
|
-
4.
|
|
101
|
-
5. Create a starter assistant (advanced settings are optional)
|
|
140
|
+
1. **Choose a provider** — Pick from all 11 supported providers (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, Together AI, Mistral, xAI, Fireworks, OpenClaw, Ollama)
|
|
141
|
+
2. **Connect provider** — Enter only required fields (API key and/or endpoint), then click **Check Connection** for live validation
|
|
142
|
+
3. **Create your agent** — Each provider gets a unique default name (e.g. Atlas for OpenAI, Claude for Anthropic, Bolt for Groq). Choose **Create & Add Another** to set up multiple providers, or **Create & Finish** to continue
|
|
143
|
+
4. **Summary** — Review all created agents and discover connectors (Discord, Slack, Telegram, WhatsApp)
|
|
102
144
|
|
|
103
145
|
Notes:
|
|
146
|
+
- You can add multiple providers in a single wizard session — configured providers are dimmed and shown as chips.
|
|
104
147
|
- Ollama checks can auto-suggest a model from the connected endpoint.
|
|
105
|
-
-
|
|
106
|
-
- You can skip setup and configure everything later in the sidebar.
|
|
148
|
+
- You can skip setup at any step and configure everything later in the sidebar.
|
|
107
149
|
|
|
108
150
|
## Features
|
|
109
151
|
|
|
110
152
|
- **15 Built-in Providers** — Claude Code CLI, OpenAI Codex CLI, OpenCode CLI, Anthropic, OpenAI, Google Gemini, DeepSeek, Groq, Together AI, Mistral AI, xAI (Grok), Fireworks AI, Ollama, plus custom OpenAI-compatible endpoints
|
|
111
153
|
- **OpenClaw Gateway** — Per-agent toggle to connect any agent to a local or remote OpenClaw gateway. Each agent gets its own gateway URL and token — run a swarm of OpenClaws from one dashboard. The `openclaw` CLI ships as a bundled dependency (no separate install needed)
|
|
112
154
|
- **OpenClaw Control Plane** — Built-in gateway connection controls, reload mode switching (hot/hybrid/full), config issue detection/repair, remote history sync, and live execution approval handling
|
|
155
|
+
- **Gateway Watchdog** — Proactive gateway health monitoring with auto-repair via `openclaw doctor`, outbound ops alerts to Discord/Slack/custom webhooks, workspace backup/rollback/history tools for agents, and connector liveness detection
|
|
113
156
|
- **Agent Builder** — Create agents with custom personalities (soul), system prompts, tools, and skills. AI-powered generation from a description
|
|
114
157
|
- **Agent Inspector Panel** — Per-agent side panel for OpenClaw file editing (`SOUL.md`, `IDENTITY.md`, `USER.md`, etc.), guided personality editing, skill install/enable/remove, permission presets, sandbox env allowlist, and cron automations
|
|
115
158
|
- **Agent Fleet Management** — Avatar seeds with generated avatars, running/approval fleet filters, soft-delete agent trash with restore/permanent delete, and approval counters in agent cards
|
|
@@ -222,22 +265,6 @@ src/
|
|
|
222
265
|
| OpenClaw | Per-Agent Gateway | Toggle in agent editor connects to any OpenClaw gateway via the bundled CLI. |
|
|
223
266
|
| Custom | API | Any OpenAI-compatible endpoint. Add via Providers sidebar. |
|
|
224
267
|
|
|
225
|
-
### OpenClaw
|
|
226
|
-
|
|
227
|
-
[OpenClaw](https://github.com/openclaw/openclaw) is an open-source autonomous AI agent that runs on your own devices. SwarmClaw includes the `openclaw` CLI as a bundled dependency — no separate install needed.
|
|
228
|
-
|
|
229
|
-
To connect an agent to an OpenClaw gateway:
|
|
230
|
-
|
|
231
|
-
1. Create or edit an agent
|
|
232
|
-
2. Toggle **OpenClaw Gateway** ON
|
|
233
|
-
3. Enter the gateway URL (e.g. `http://192.168.1.50:18789` or `https://my-vps:18789`)
|
|
234
|
-
4. Add a gateway token if authentication is enabled on the remote gateway
|
|
235
|
-
5. Click **Connect** — approve the device in your gateway's dashboard if prompted, then **Retry Connection**
|
|
236
|
-
|
|
237
|
-
Each agent can point to a **different** OpenClaw gateway — one local, several remote. This is how you manage a **swarm of OpenClaws** from a single dashboard.
|
|
238
|
-
|
|
239
|
-
URLs without a protocol are auto-prefixed with `http://`. For remote gateways with TLS, use `https://` explicitly.
|
|
240
|
-
|
|
241
268
|
## Chat Connectors
|
|
242
269
|
|
|
243
270
|
Bridge any agent to a chat platform:
|
|
@@ -626,7 +653,10 @@ swarmclaw tasks create --title "Fix flaky CI test" --description "Stabilize retr
|
|
|
626
653
|
# run setup diagnostics
|
|
627
654
|
swarmclaw setup doctor
|
|
628
655
|
|
|
629
|
-
#
|
|
656
|
+
# interactive setup (walks through provider selection, supports multiple providers)
|
|
657
|
+
swarmclaw setup init
|
|
658
|
+
|
|
659
|
+
# or non-interactive setup with flags
|
|
630
660
|
swarmclaw setup init --provider openai --api-key "$OPENAI_API_KEY"
|
|
631
661
|
|
|
632
662
|
# run memory maintenance analysis
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -78,9 +78,12 @@
|
|
|
78
78
|
"exceljs": "^4.4.0",
|
|
79
79
|
"grammy": "^1.40.0",
|
|
80
80
|
"highlight.js": "^11.11.1",
|
|
81
|
+
"imapflow": "^1.2.11",
|
|
81
82
|
"lucide-react": "^0.574.0",
|
|
83
|
+
"mailparser": "^3.9.3",
|
|
82
84
|
"next": "16.1.6",
|
|
83
85
|
"next-themes": "^0.4.6",
|
|
86
|
+
"nodemailer": "^8.0.1",
|
|
84
87
|
"openclaw": "^2026.2.26",
|
|
85
88
|
"pdf-parse": "^2.4.5",
|
|
86
89
|
"qrcode": "^1.5.4",
|
|
@@ -103,7 +106,9 @@
|
|
|
103
106
|
"devDependencies": {
|
|
104
107
|
"@tailwindcss/postcss": "^4",
|
|
105
108
|
"@types/better-sqlite3": "^7.6.13",
|
|
109
|
+
"@types/mailparser": "^3.4.6",
|
|
106
110
|
"@types/node": "^20",
|
|
111
|
+
"@types/nodemailer": "^7.0.11",
|
|
107
112
|
"@types/qrcode": "^1.5.6",
|
|
108
113
|
"@types/react": "^19",
|
|
109
114
|
"@types/react-dom": "^19",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { loadAgents, saveAgents, logActivity } from '@/lib/server/storage'
|
|
3
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
4
|
+
|
|
5
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
6
|
+
const { id } = await params
|
|
7
|
+
const agents = loadAgents({ includeTrashed: true })
|
|
8
|
+
const source = agents[id]
|
|
9
|
+
if (!source) {
|
|
10
|
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const newId = crypto.randomUUID()
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
|
|
16
|
+
// Deep-copy the source agent, then override clone-specific fields
|
|
17
|
+
const cloned = JSON.parse(JSON.stringify(source)) as Record<string, unknown>
|
|
18
|
+
cloned.id = newId
|
|
19
|
+
cloned.name = `${source.name} (Copy)`
|
|
20
|
+
cloned.createdAt = now
|
|
21
|
+
cloned.updatedAt = now
|
|
22
|
+
cloned.totalCost = 0
|
|
23
|
+
cloned.lastUsedAt = undefined
|
|
24
|
+
cloned.threadSessionId = null
|
|
25
|
+
cloned.pinned = false
|
|
26
|
+
cloned.trashedAt = undefined
|
|
27
|
+
|
|
28
|
+
agents[newId] = cloned
|
|
29
|
+
saveAgents(agents)
|
|
30
|
+
logActivity({
|
|
31
|
+
entityType: 'agent',
|
|
32
|
+
entityId: newId,
|
|
33
|
+
action: 'created',
|
|
34
|
+
actor: 'user',
|
|
35
|
+
summary: `Agent cloned from "${source.name}": "${cloned.name}"`,
|
|
36
|
+
})
|
|
37
|
+
notify('agents')
|
|
38
|
+
|
|
39
|
+
return NextResponse.json(cloned)
|
|
40
|
+
}
|
|
@@ -3,32 +3,57 @@ import { genId } from '@/lib/id'
|
|
|
3
3
|
import { loadAgents, saveAgents, logActivity } from '@/lib/server/storage'
|
|
4
4
|
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
|
|
5
5
|
import { notify } from '@/lib/server/ws-hub'
|
|
6
|
+
import { getAgentMonthlySpend } from '@/lib/server/cost'
|
|
7
|
+
import { AgentCreateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
8
|
+
import { z } from 'zod'
|
|
6
9
|
export const dynamic = 'force-dynamic'
|
|
7
10
|
|
|
8
11
|
|
|
9
|
-
export async function GET(
|
|
10
|
-
|
|
12
|
+
export async function GET(req: Request) {
|
|
13
|
+
const agents = loadAgents()
|
|
14
|
+
// Enrich agents that have a monthly budget with current spend
|
|
15
|
+
for (const agent of Object.values(agents)) {
|
|
16
|
+
if (typeof agent.monthlyBudget === 'number' && agent.monthlyBudget > 0) {
|
|
17
|
+
agent.monthlySpend = getAgentMonthlySpend(agent.id)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { searchParams } = new URL(req.url)
|
|
22
|
+
const limitParam = searchParams.get('limit')
|
|
23
|
+
if (!limitParam) return NextResponse.json(agents)
|
|
24
|
+
|
|
25
|
+
const limit = Math.max(1, Number(limitParam) || 50)
|
|
26
|
+
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
|
|
27
|
+
const all = Object.values(agents).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
28
|
+
const items = all.slice(offset, offset + limit)
|
|
29
|
+
return NextResponse.json({ items, total: all.length, hasMore: offset + limit < all.length })
|
|
11
30
|
}
|
|
12
31
|
|
|
13
32
|
export async function POST(req: Request) {
|
|
14
|
-
const
|
|
33
|
+
const raw = await req.json()
|
|
34
|
+
const parsed = AgentCreateSchema.safeParse(raw)
|
|
35
|
+
if (!parsed.success) {
|
|
36
|
+
return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
|
|
37
|
+
}
|
|
38
|
+
const body = parsed.data
|
|
15
39
|
const id = genId()
|
|
16
40
|
const now = Date.now()
|
|
17
41
|
const agents = loadAgents()
|
|
18
42
|
agents[id] = {
|
|
19
43
|
id,
|
|
20
|
-
name: body.name
|
|
21
|
-
description: body.description
|
|
22
|
-
systemPrompt: body.systemPrompt
|
|
23
|
-
provider: body.provider
|
|
24
|
-
model: body.model
|
|
25
|
-
credentialId: body.credentialId
|
|
26
|
-
apiEndpoint: normalizeProviderEndpoint(body.provider
|
|
27
|
-
isOrchestrator: body.isOrchestrator
|
|
28
|
-
subAgentIds: body.subAgentIds
|
|
29
|
-
tools: body.tools
|
|
30
|
-
capabilities: body.capabilities
|
|
44
|
+
name: body.name,
|
|
45
|
+
description: body.description,
|
|
46
|
+
systemPrompt: body.systemPrompt,
|
|
47
|
+
provider: body.provider,
|
|
48
|
+
model: body.model,
|
|
49
|
+
credentialId: body.credentialId,
|
|
50
|
+
apiEndpoint: normalizeProviderEndpoint(body.provider, body.apiEndpoint || null),
|
|
51
|
+
isOrchestrator: body.isOrchestrator,
|
|
52
|
+
subAgentIds: body.subAgentIds,
|
|
53
|
+
tools: body.tools,
|
|
54
|
+
capabilities: body.capabilities,
|
|
31
55
|
thinkingLevel: body.thinkingLevel || undefined,
|
|
56
|
+
soul: body.soul || undefined,
|
|
32
57
|
createdAt: now,
|
|
33
58
|
updatedAt: now,
|
|
34
59
|
}
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
buildSyntheticSession,
|
|
14
14
|
buildAgentSystemPromptForChatroom,
|
|
15
15
|
buildHistoryForAgent,
|
|
16
|
+
isMuted,
|
|
16
17
|
} from '@/lib/server/chatroom-helpers'
|
|
17
18
|
import { filterHealthyChatroomAgents } from '@/lib/server/chatroom-health'
|
|
19
|
+
import { evaluateRoutingRules } from '@/lib/server/chatroom-routing'
|
|
18
20
|
import { markProviderFailure, markProviderSuccess } from '@/lib/server/provider-health'
|
|
19
21
|
import type { Chatroom, ChatroomMessage, Agent } from '@/types'
|
|
20
22
|
|
|
@@ -48,7 +50,12 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
48
50
|
// Persist incoming message
|
|
49
51
|
const senderName = senderId === 'user' ? 'You' : (agents[senderId]?.name || senderId)
|
|
50
52
|
let mentions = parseMentions(text, agents, chatroom.agentIds)
|
|
51
|
-
//
|
|
53
|
+
// Routing rules: if no explicit mentions, evaluate keyword/capability rules
|
|
54
|
+
if (mentions.length === 0 && chatroom.routingRules?.length) {
|
|
55
|
+
const agentList = chatroom.agentIds.map((aid) => agents[aid]).filter(Boolean)
|
|
56
|
+
mentions = evaluateRoutingRules(text, chatroom.routingRules, agentList)
|
|
57
|
+
}
|
|
58
|
+
// Auto-address: if enabled and still no mentions, address all agents
|
|
52
59
|
if (chatroom.autoAddress && mentions.length === 0) {
|
|
53
60
|
mentions = [...chatroom.agentIds]
|
|
54
61
|
}
|
|
@@ -129,6 +136,15 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
129
136
|
const agent = agents[item.agentId]
|
|
130
137
|
if (!agent) return []
|
|
131
138
|
|
|
139
|
+
// Skip muted agents
|
|
140
|
+
const freshForMuteCheck = loadChatrooms()[id] as Chatroom | undefined
|
|
141
|
+
if (freshForMuteCheck && isMuted(freshForMuteCheck, item.agentId)) {
|
|
142
|
+
writeEvent({ t: 'cr_agent_start', agentId: agent.id, agentName: agent.name })
|
|
143
|
+
writeEvent({ t: 'err', text: `${agent.name} is muted`, agentId: agent.id, agentName: agent.name })
|
|
144
|
+
writeEvent({ t: 'cr_agent_done', agentId: agent.id, agentName: agent.name })
|
|
145
|
+
return []
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
// Pre-flight: check if the agent's provider is usable before attempting to stream
|
|
133
149
|
const providerInfo = getProvider(agent.provider)
|
|
134
150
|
const apiKey = resolveApiKey(agent.credentialId)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import crypto from 'crypto'
|
|
3
|
+
import { loadChatrooms, saveChatrooms, appendModerationLog } from '@/lib/server/storage'
|
|
4
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
5
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
6
|
+
import { getMembers } from '@/lib/server/chatroom-helpers'
|
|
7
|
+
import type { Chatroom, ChatroomMember } from '@/types'
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-dynamic'
|
|
10
|
+
|
|
11
|
+
interface ModerationBody {
|
|
12
|
+
action: 'delete-message' | 'mute' | 'unmute' | 'set-role'
|
|
13
|
+
targetAgentId: string
|
|
14
|
+
messageId?: string
|
|
15
|
+
role?: 'admin' | 'moderator' | 'member'
|
|
16
|
+
muteDurationMinutes?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isValidAction(action: unknown): action is ModerationBody['action'] {
|
|
20
|
+
return typeof action === 'string' && ['delete-message', 'mute', 'unmute', 'set-role'].includes(action)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isValidRole(role: unknown): role is ChatroomMember['role'] {
|
|
24
|
+
return typeof role === 'string' && ['admin', 'moderator', 'member'].includes(role)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
28
|
+
const { id } = await params
|
|
29
|
+
const body = await req.json() as Record<string, unknown>
|
|
30
|
+
|
|
31
|
+
const chatrooms = loadChatrooms()
|
|
32
|
+
const chatroom = chatrooms[id] as Chatroom | undefined
|
|
33
|
+
if (!chatroom) return notFound()
|
|
34
|
+
|
|
35
|
+
const action = body.action
|
|
36
|
+
const targetAgentId = typeof body.targetAgentId === 'string' ? body.targetAgentId : ''
|
|
37
|
+
|
|
38
|
+
if (!isValidAction(action)) {
|
|
39
|
+
return NextResponse.json({ error: 'Invalid action. Must be: delete-message, mute, unmute, or set-role' }, { status: 400 })
|
|
40
|
+
}
|
|
41
|
+
if (!targetAgentId) {
|
|
42
|
+
return NextResponse.json({ error: 'targetAgentId is required' }, { status: 400 })
|
|
43
|
+
}
|
|
44
|
+
if (!chatroom.agentIds.includes(targetAgentId)) {
|
|
45
|
+
return NextResponse.json({ error: 'Agent is not a member of this chatroom' }, { status: 400 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Ensure members array exists (backward compat)
|
|
49
|
+
if (!chatroom.members) {
|
|
50
|
+
chatroom.members = getMembers(chatroom)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const logId = crypto.randomBytes(8).toString('hex')
|
|
54
|
+
|
|
55
|
+
switch (action) {
|
|
56
|
+
case 'delete-message': {
|
|
57
|
+
const messageId = typeof body.messageId === 'string' ? body.messageId : ''
|
|
58
|
+
if (!messageId) {
|
|
59
|
+
return NextResponse.json({ error: 'messageId is required for delete-message' }, { status: 400 })
|
|
60
|
+
}
|
|
61
|
+
const msgIndex = chatroom.messages.findIndex((m) => m.id === messageId)
|
|
62
|
+
if (msgIndex === -1) {
|
|
63
|
+
return NextResponse.json({ error: 'Message not found' }, { status: 404 })
|
|
64
|
+
}
|
|
65
|
+
const deleted = chatroom.messages.splice(msgIndex, 1)[0]
|
|
66
|
+
// Also remove from pinned if it was pinned
|
|
67
|
+
if (chatroom.pinnedMessageIds) {
|
|
68
|
+
chatroom.pinnedMessageIds = chatroom.pinnedMessageIds.filter((pid) => pid !== messageId)
|
|
69
|
+
}
|
|
70
|
+
appendModerationLog(logId, {
|
|
71
|
+
id: logId,
|
|
72
|
+
chatroomId: id,
|
|
73
|
+
action: 'delete-message',
|
|
74
|
+
targetAgentId,
|
|
75
|
+
messageId,
|
|
76
|
+
messagePreview: deleted.text.slice(0, 100),
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
})
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'mute': {
|
|
83
|
+
const minutes = typeof body.muteDurationMinutes === 'number' && body.muteDurationMinutes > 0
|
|
84
|
+
? body.muteDurationMinutes
|
|
85
|
+
: 30
|
|
86
|
+
const mutedUntil = new Date(Date.now() + minutes * 60 * 1000).toISOString()
|
|
87
|
+
const memberIdx = chatroom.members.findIndex((m) => m.agentId === targetAgentId)
|
|
88
|
+
if (memberIdx >= 0) {
|
|
89
|
+
chatroom.members[memberIdx].mutedUntil = mutedUntil
|
|
90
|
+
} else {
|
|
91
|
+
chatroom.members.push({ agentId: targetAgentId, role: 'member', mutedUntil })
|
|
92
|
+
}
|
|
93
|
+
appendModerationLog(logId, {
|
|
94
|
+
id: logId,
|
|
95
|
+
chatroomId: id,
|
|
96
|
+
action: 'mute',
|
|
97
|
+
targetAgentId,
|
|
98
|
+
muteDurationMinutes: minutes,
|
|
99
|
+
mutedUntil,
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
})
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'unmute': {
|
|
106
|
+
const memberIdx = chatroom.members.findIndex((m) => m.agentId === targetAgentId)
|
|
107
|
+
if (memberIdx >= 0) {
|
|
108
|
+
delete chatroom.members[memberIdx].mutedUntil
|
|
109
|
+
}
|
|
110
|
+
appendModerationLog(logId, {
|
|
111
|
+
id: logId,
|
|
112
|
+
chatroomId: id,
|
|
113
|
+
action: 'unmute',
|
|
114
|
+
targetAgentId,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
})
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'set-role': {
|
|
121
|
+
const role = body.role
|
|
122
|
+
if (!isValidRole(role)) {
|
|
123
|
+
return NextResponse.json({ error: 'role must be: admin, moderator, or member' }, { status: 400 })
|
|
124
|
+
}
|
|
125
|
+
const memberIdx = chatroom.members.findIndex((m) => m.agentId === targetAgentId)
|
|
126
|
+
if (memberIdx >= 0) {
|
|
127
|
+
chatroom.members[memberIdx].role = role
|
|
128
|
+
} else {
|
|
129
|
+
chatroom.members.push({ agentId: targetAgentId, role })
|
|
130
|
+
}
|
|
131
|
+
appendModerationLog(logId, {
|
|
132
|
+
id: logId,
|
|
133
|
+
chatroomId: id,
|
|
134
|
+
action: 'set-role',
|
|
135
|
+
targetAgentId,
|
|
136
|
+
role,
|
|
137
|
+
timestamp: Date.now(),
|
|
138
|
+
})
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
chatroom.updatedAt = Date.now()
|
|
144
|
+
chatrooms[id] = chatroom
|
|
145
|
+
saveChatrooms(chatrooms)
|
|
146
|
+
notify('chatrooms')
|
|
147
|
+
notify(`chatroom:${id}`)
|
|
148
|
+
|
|
149
|
+
return NextResponse.json(chatroom)
|
|
150
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { loadChatrooms, saveChatrooms, loadAgents } from '@/lib/server/storage'
|
|
2
|
+
import { loadChatrooms, saveChatrooms, loadAgents, loadConnectors, saveConnectors } from '@/lib/server/storage'
|
|
3
3
|
import { notify } from '@/lib/server/ws-hub'
|
|
4
4
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
5
5
|
import { genId } from '@/lib/id'
|
|
@@ -27,6 +27,9 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
27
27
|
if (body.autoAddress !== undefined) {
|
|
28
28
|
chatroom.autoAddress = Boolean(body.autoAddress)
|
|
29
29
|
}
|
|
30
|
+
if (body.routingRules !== undefined) {
|
|
31
|
+
chatroom.routingRules = Array.isArray(body.routingRules) ? body.routingRules : undefined
|
|
32
|
+
}
|
|
30
33
|
|
|
31
34
|
// Diff agentIds and inject join/leave system messages
|
|
32
35
|
if (Array.isArray(body.agentIds)) {
|
|
@@ -91,6 +94,21 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|
|
91
94
|
const chatrooms = loadChatrooms()
|
|
92
95
|
if (!chatrooms[id]) return notFound()
|
|
93
96
|
|
|
97
|
+
// Cascade: null out chatroomId on any connectors that reference this chatroom
|
|
98
|
+
const connectors = loadConnectors()
|
|
99
|
+
let connectorsDirty = false
|
|
100
|
+
for (const connector of Object.values(connectors)) {
|
|
101
|
+
if (connector.chatroomId === id) {
|
|
102
|
+
connector.chatroomId = null
|
|
103
|
+
connector.updatedAt = Date.now()
|
|
104
|
+
connectorsDirty = true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (connectorsDirty) {
|
|
108
|
+
saveConnectors(connectors)
|
|
109
|
+
notify('connectors')
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
delete chatrooms[id]
|
|
95
113
|
saveChatrooms(chatrooms)
|
|
96
114
|
notify('chatrooms')
|
|
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { genId } from '@/lib/id'
|
|
3
3
|
import { loadChatrooms, saveChatrooms, loadAgents } from '@/lib/server/storage'
|
|
4
4
|
import { notify } from '@/lib/server/ws-hub'
|
|
5
|
+
import { ChatroomCreateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
6
|
+
import { z } from 'zod'
|
|
5
7
|
import type { Chatroom, ChatroomMessage } from '@/types'
|
|
6
8
|
|
|
7
9
|
export const dynamic = 'force-dynamic'
|
|
@@ -12,11 +14,16 @@ export async function GET() {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export async function POST(req: Request) {
|
|
15
|
-
const
|
|
17
|
+
const raw = await req.json()
|
|
18
|
+
const parsed = ChatroomCreateSchema.safeParse(raw)
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
|
|
21
|
+
}
|
|
22
|
+
const body = parsed.data
|
|
16
23
|
const chatrooms = loadChatrooms()
|
|
17
24
|
const id = genId()
|
|
18
25
|
|
|
19
|
-
const requestedAgentIds: string[] =
|
|
26
|
+
const requestedAgentIds: string[] = body.agentIds
|
|
20
27
|
const knownAgents = loadAgents()
|
|
21
28
|
const invalidAgentIds = requestedAgentIds.filter((agentId) => !knownAgents[agentId])
|
|
22
29
|
if (invalidAgentIds.length > 0) {
|
|
@@ -51,6 +58,9 @@ export async function POST(req: Request) {
|
|
|
51
58
|
messages: joinMessages,
|
|
52
59
|
chatMode,
|
|
53
60
|
autoAddress,
|
|
61
|
+
...(Array.isArray(body.routingRules) && body.routingRules.length > 0
|
|
62
|
+
? { routingRules: body.routingRules }
|
|
63
|
+
: {}),
|
|
54
64
|
createdAt: now,
|
|
55
65
|
updatedAt: now,
|
|
56
66
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { loadConnectors, loadConnectorHealth } from '@/lib/server/storage'
|
|
3
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
|
+
import type { ConnectorHealthEvent } from '@/types'
|
|
5
|
+
|
|
6
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
7
|
+
const { id } = await params
|
|
8
|
+
const connectors = loadConnectors()
|
|
9
|
+
if (!connectors[id]) return notFound()
|
|
10
|
+
|
|
11
|
+
const url = new URL(req.url)
|
|
12
|
+
const since = url.searchParams.get('since')
|
|
13
|
+
|
|
14
|
+
const allHealth = loadConnectorHealth()
|
|
15
|
+
const events: ConnectorHealthEvent[] = []
|
|
16
|
+
|
|
17
|
+
for (const raw of Object.values(allHealth)) {
|
|
18
|
+
const entry = raw as ConnectorHealthEvent
|
|
19
|
+
if (entry.connectorId !== id) continue
|
|
20
|
+
if (since && entry.timestamp < since) continue
|
|
21
|
+
events.push(entry)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Sort by timestamp ascending
|
|
25
|
+
events.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
26
|
+
|
|
27
|
+
// Compute uptime percentage
|
|
28
|
+
const uptimePercent = computeUptime(events)
|
|
29
|
+
|
|
30
|
+
return NextResponse.json({ events, uptimePercent })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function computeUptime(events: ConnectorHealthEvent[]): number {
|
|
34
|
+
if (events.length === 0) return 0
|
|
35
|
+
|
|
36
|
+
const firstTime = new Date(events[0].timestamp).getTime()
|
|
37
|
+
const now = Date.now()
|
|
38
|
+
const totalMs = now - firstTime
|
|
39
|
+
if (totalMs <= 0) return 100
|
|
40
|
+
|
|
41
|
+
let uptimeMs = 0
|
|
42
|
+
let lastUpAt: number | null = null
|
|
43
|
+
|
|
44
|
+
for (const ev of events) {
|
|
45
|
+
const t = new Date(ev.timestamp).getTime()
|
|
46
|
+
if (ev.event === 'started' || ev.event === 'reconnected') {
|
|
47
|
+
if (lastUpAt === null) {
|
|
48
|
+
lastUpAt = t
|
|
49
|
+
}
|
|
50
|
+
} else if (ev.event === 'stopped' || ev.event === 'error' || ev.event === 'disconnected') {
|
|
51
|
+
if (lastUpAt !== null) {
|
|
52
|
+
uptimeMs += t - lastUpAt
|
|
53
|
+
lastUpAt = null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If still up, count time until now
|
|
59
|
+
if (lastUpAt !== null) {
|
|
60
|
+
uptimeMs += now - lastUpAt
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Math.round((uptimeMs / totalMs) * 10000) / 100
|
|
64
|
+
}
|
|
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { genId } from '@/lib/id'
|
|
3
3
|
import { loadConnectors, saveConnectors } from '@/lib/server/storage'
|
|
4
4
|
import { notify } from '@/lib/server/ws-hub'
|
|
5
|
+
import { ConnectorCreateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
6
|
+
import { z } from 'zod'
|
|
5
7
|
import type { Connector } from '@/types'
|
|
6
8
|
export const dynamic = 'force-dynamic'
|
|
7
9
|
|
|
@@ -10,7 +12,7 @@ export async function GET(_req: Request) {
|
|
|
10
12
|
const connectors = loadConnectors()
|
|
11
13
|
// Merge runtime status from manager
|
|
12
14
|
try {
|
|
13
|
-
const { getConnectorStatus, isConnectorAuthenticated, hasConnectorCredentials, getConnectorQR } = await import('@/lib/server/connectors/manager')
|
|
15
|
+
const { getConnectorStatus, isConnectorAuthenticated, hasConnectorCredentials, getConnectorQR, getReconnectState } = await import('@/lib/server/connectors/manager')
|
|
14
16
|
for (const c of Object.values(connectors) as Connector[]) {
|
|
15
17
|
c.status = getConnectorStatus(c.id)
|
|
16
18
|
if (c.platform === 'whatsapp') {
|
|
@@ -19,13 +21,26 @@ export async function GET(_req: Request) {
|
|
|
19
21
|
const qr = getConnectorQR(c.id)
|
|
20
22
|
if (qr) c.qrDataUrl = qr
|
|
21
23
|
}
|
|
24
|
+
// Surface reconnect state if connector is in a recovery cycle
|
|
25
|
+
const rState = getReconnectState(c.id)
|
|
26
|
+
if (rState) {
|
|
27
|
+
const ext = c as unknown as Record<string, unknown>
|
|
28
|
+
ext.reconnectAttempts = rState.attempts
|
|
29
|
+
ext.nextRetryAt = rState.nextRetryAt
|
|
30
|
+
ext.reconnectError = rState.error
|
|
31
|
+
}
|
|
22
32
|
}
|
|
23
33
|
} catch { /* manager not loaded yet */ }
|
|
24
34
|
return NextResponse.json(connectors)
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export async function POST(req: Request) {
|
|
28
|
-
const
|
|
38
|
+
const raw = await req.json()
|
|
39
|
+
const parsed = ConnectorCreateSchema.safeParse(raw)
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
|
|
42
|
+
}
|
|
43
|
+
const body = parsed.data
|
|
29
44
|
const connectors = loadConnectors()
|
|
30
45
|
const id = genId()
|
|
31
46
|
|