@swarmclawai/swarmclaw 1.4.0 → 1.4.3
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 +13 -71
- package/next.config.ts +9 -4
- package/package.json +10 -8
- package/scripts/build-bootstrap-env.mjs +24 -0
- package/scripts/run-next-build.mjs +120 -0
- package/scripts/run-next-typegen.mjs +61 -0
- package/src/app/api/approvals/route.test.ts +29 -3
- package/src/app/api/approvals/route.ts +13 -7
- package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
- package/src/app/api/chats/[id]/chat/route.ts +24 -8
- package/src/app/api/chats/chat-route.test.ts +68 -0
- package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
- package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
- package/src/app/api/logs/route.test.ts +61 -0
- package/src/app/api/logs/route.ts +35 -0
- package/src/app/api/swarmdock/route.ts +25 -0
- package/src/app/api/swarmfeed/posts/route.ts +44 -6
- package/src/app/api/swarmfeed/route.ts +4 -0
- package/src/app/api/tts/route.test.ts +82 -0
- package/src/app/api/tts/route.ts +13 -6
- package/src/app/api/tts/stream/route.ts +12 -5
- package/src/app/error.tsx +32 -0
- package/src/app/global-error.tsx +33 -0
- package/src/app/marketplace/page.tsx +7 -0
- package/src/cli/index.js +10 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-sheet.tsx +10 -0
- package/src/components/layout/error-boundary.tsx +12 -30
- package/src/components/layout/error-fallback.tsx +61 -0
- package/src/components/layout/sidebar-rail.tsx +5 -0
- package/src/features/swarmdock/agent-marketplace-settings.tsx +303 -0
- package/src/features/swarmdock/marketplace-page.tsx +189 -0
- package/src/features/swarmfeed/feed-page.tsx +3 -33
- package/src/features/swarmfeed/queries.ts +3 -3
- package/src/lib/app/navigation.ts +1 -0
- package/src/lib/app/report-client-error.ts +52 -0
- package/src/lib/app/view-constants.ts +9 -1
- package/src/lib/providers/anthropic.ts +9 -1
- package/src/lib/providers/ollama.ts +34 -14
- package/src/lib/providers/openai.ts +9 -1
- package/src/lib/providers/openclaw.ts +3 -3
- package/src/lib/server/agents/agent-service.ts +18 -0
- package/src/lib/server/agents/agent-swarm-registration.ts +35 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
- package/src/lib/server/connectors/swarmdock.ts +29 -7
- package/src/lib/server/messages/message-repository.ts +31 -0
- package/src/lib/server/provider-health.ts +19 -3
- package/src/lib/server/safe-parse-body.test.ts +32 -0
- package/src/lib/server/safe-parse-body.ts +20 -3
- package/src/lib/server/session-tools/index.ts +4 -0
- package/src/lib/server/session-tools/swarmdock.ts +104 -0
- package/src/lib/server/session-tools/swarmfeed.ts +150 -0
- package/src/lib/server/storage-normalization.ts +10 -0
- package/src/lib/server/storage.ts +13 -4
- package/src/lib/swarmfeed-client.ts +1 -1
- package/src/lib/tool-definitions.ts +2 -0
- package/src/types/agent.ts +23 -0
- package/src/types/session.ts +1 -1
- package/tsconfig.json +1 -2
- package/src/.env.local +0 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('chat route rejects malformed JSON with a 400 before queueing work', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
payload: { error?: string }
|
|
10
|
+
runCount: number
|
|
11
|
+
}>(`
|
|
12
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
13
|
+
const routeMod = await import('./src/app/api/chats/[id]/chat/route')
|
|
14
|
+
const runsMod = await import('@/lib/server/runtime/session-run-manager')
|
|
15
|
+
const storage = storageMod.default || storageMod
|
|
16
|
+
const route = routeMod.default || routeMod
|
|
17
|
+
const runs = runsMod.default || runsMod
|
|
18
|
+
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
storage.saveAgents({
|
|
21
|
+
agent_1: {
|
|
22
|
+
id: 'agent_1',
|
|
23
|
+
name: 'Malformed Agent',
|
|
24
|
+
provider: 'openai',
|
|
25
|
+
model: 'gpt-4o-mini',
|
|
26
|
+
extensions: [],
|
|
27
|
+
createdAt: now,
|
|
28
|
+
updatedAt: now,
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
storage.saveSessions({
|
|
32
|
+
sess_1: {
|
|
33
|
+
id: 'sess_1',
|
|
34
|
+
name: 'Malformed Session',
|
|
35
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
36
|
+
user: 'workbench',
|
|
37
|
+
provider: 'openai',
|
|
38
|
+
model: 'gpt-4o-mini',
|
|
39
|
+
claudeSessionId: null,
|
|
40
|
+
messages: [],
|
|
41
|
+
createdAt: now,
|
|
42
|
+
lastActiveAt: now,
|
|
43
|
+
sessionType: 'human',
|
|
44
|
+
agentId: 'agent_1',
|
|
45
|
+
extensions: [],
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const response = await route.POST(
|
|
50
|
+
new Request('http://local/api/chats/sess_1/chat', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: '{bad-json',
|
|
54
|
+
}),
|
|
55
|
+
{ params: Promise.resolve({ id: 'sess_1' }) },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.log(JSON.stringify({
|
|
59
|
+
status: response.status,
|
|
60
|
+
payload: await response.json(),
|
|
61
|
+
runCount: runs.listRuns({ sessionId: 'sess_1' }).length,
|
|
62
|
+
}))
|
|
63
|
+
`, { prefix: 'swarmclaw-chat-route-invalid-json-' })
|
|
64
|
+
|
|
65
|
+
assert.equal(output.status, 400)
|
|
66
|
+
assert.equal(output.payload.error, 'Invalid or missing request body')
|
|
67
|
+
assert.equal(output.runCount, 0)
|
|
68
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('connector doctor route rejects malformed JSON with a 400', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
payload: { error?: string }
|
|
10
|
+
}>(`
|
|
11
|
+
const repoMod = await import('./src/lib/server/connectors/connector-repository')
|
|
12
|
+
const routeMod = await import('./src/app/api/connectors/[id]/doctor/route')
|
|
13
|
+
const repo = repoMod.default || repoMod
|
|
14
|
+
const route = routeMod.default || routeMod
|
|
15
|
+
|
|
16
|
+
repo.saveConnectors({
|
|
17
|
+
conn_1: {
|
|
18
|
+
id: 'conn_1',
|
|
19
|
+
name: 'Doctor Test',
|
|
20
|
+
platform: 'discord',
|
|
21
|
+
agentId: 'agent_1',
|
|
22
|
+
chatroomId: null,
|
|
23
|
+
credentialId: null,
|
|
24
|
+
config: {},
|
|
25
|
+
isEnabled: true,
|
|
26
|
+
status: 'running',
|
|
27
|
+
createdAt: 1,
|
|
28
|
+
updatedAt: 1,
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const response = await route.POST(new Request('http://local/api/connectors/conn_1/doctor', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'content-type': 'application/json' },
|
|
35
|
+
body: '{bad-json',
|
|
36
|
+
}), { params: Promise.resolve({ id: 'conn_1' }) })
|
|
37
|
+
|
|
38
|
+
console.log(JSON.stringify({
|
|
39
|
+
status: response.status,
|
|
40
|
+
payload: await response.json(),
|
|
41
|
+
}))
|
|
42
|
+
`, { prefix: 'swarmclaw-connector-doctor-route-' })
|
|
43
|
+
|
|
44
|
+
assert.equal(output.status, 400)
|
|
45
|
+
assert.equal(output.payload.error, 'Invalid or missing request body')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('connector doctor route returns a preview report for valid input', () => {
|
|
49
|
+
const output = runWithTempDataDir<{
|
|
50
|
+
status: number
|
|
51
|
+
payload: { warnings?: string[]; policy?: { mode?: string } }
|
|
52
|
+
}>(`
|
|
53
|
+
const repoMod = await import('./src/lib/server/connectors/connector-repository')
|
|
54
|
+
const routeMod = await import('./src/app/api/connectors/[id]/doctor/route')
|
|
55
|
+
const repo = repoMod.default || repoMod
|
|
56
|
+
const route = routeMod.default || routeMod
|
|
57
|
+
|
|
58
|
+
repo.saveConnectors({
|
|
59
|
+
conn_1: {
|
|
60
|
+
id: 'conn_1',
|
|
61
|
+
name: 'Doctor Test',
|
|
62
|
+
platform: 'discord',
|
|
63
|
+
agentId: 'agent_1',
|
|
64
|
+
chatroomId: null,
|
|
65
|
+
credentialId: null,
|
|
66
|
+
config: {},
|
|
67
|
+
isEnabled: true,
|
|
68
|
+
status: 'running',
|
|
69
|
+
createdAt: 1,
|
|
70
|
+
updatedAt: 1,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const response = await route.POST(new Request('http://local/api/connectors/conn_1/doctor', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
sampleMsg: {
|
|
79
|
+
channelId: 'channel-1',
|
|
80
|
+
channelName: 'general',
|
|
81
|
+
senderId: 'user-1',
|
|
82
|
+
senderName: 'User',
|
|
83
|
+
text: 'hello',
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
}), { params: Promise.resolve({ id: 'conn_1' }) })
|
|
87
|
+
|
|
88
|
+
console.log(JSON.stringify({
|
|
89
|
+
status: response.status,
|
|
90
|
+
payload: await response.json(),
|
|
91
|
+
}))
|
|
92
|
+
`, { prefix: 'swarmclaw-connector-doctor-route-' })
|
|
93
|
+
|
|
94
|
+
assert.equal(output.status, 200)
|
|
95
|
+
assert.ok(Array.isArray(output.payload.warnings))
|
|
96
|
+
assert.ok(output.payload.policy)
|
|
97
|
+
})
|
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
2
4
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
3
5
|
import { buildConnectorDoctorPreview, buildConnectorDoctorReport, type ConnectorDoctorPreviewInput } from '@/lib/server/connectors/doctor'
|
|
4
6
|
import { loadConnectors } from '@/lib/server/connectors/connector-repository'
|
|
7
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
5
8
|
|
|
6
9
|
export const dynamic = 'force-dynamic'
|
|
7
10
|
|
|
11
|
+
const ConnectorDoctorPreviewSchema: z.ZodType<ConnectorDoctorPreviewInput> = z.object({
|
|
12
|
+
id: z.unknown().optional(),
|
|
13
|
+
name: z.unknown().optional(),
|
|
14
|
+
platform: z.unknown().optional(),
|
|
15
|
+
agentId: z.unknown().optional(),
|
|
16
|
+
chatroomId: z.unknown().optional(),
|
|
17
|
+
credentialId: z.unknown().optional(),
|
|
18
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
19
|
+
sampleMsg: z.object({
|
|
20
|
+
channelId: z.string().optional(),
|
|
21
|
+
channelName: z.string().optional(),
|
|
22
|
+
senderId: z.string().optional(),
|
|
23
|
+
senderName: z.string().optional(),
|
|
24
|
+
text: z.string().optional(),
|
|
25
|
+
isGroup: z.boolean().optional(),
|
|
26
|
+
messageId: z.string().optional(),
|
|
27
|
+
replyToMessageId: z.string().optional(),
|
|
28
|
+
threadId: z.string().optional(),
|
|
29
|
+
}).passthrough().nullable().optional(),
|
|
30
|
+
}).passthrough()
|
|
31
|
+
|
|
8
32
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
9
33
|
const { id } = await params
|
|
10
34
|
const connectors = loadConnectors()
|
|
@@ -20,7 +44,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
20
44
|
const baseConnector = connectors[id]
|
|
21
45
|
if (!baseConnector) return notFound()
|
|
22
46
|
|
|
23
|
-
const body = await req
|
|
47
|
+
const { data: body, error } = await safeParseBody(req, ConnectorDoctorPreviewSchema)
|
|
48
|
+
if (error) return error
|
|
24
49
|
const connector = buildConnectorDoctorPreview({ baseConnector, input: body, fallbackId: id })
|
|
25
50
|
return NextResponse.json(buildConnectorDoctorReport(connector, body.sampleMsg, { baseConnector }))
|
|
26
51
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './[id]/doctor/route.test'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('logs route accepts client-side error reports and persists them', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
payload: { ok?: boolean }
|
|
10
|
+
entries: Array<{ tag?: string; message?: string; data?: string }>
|
|
11
|
+
}>(`
|
|
12
|
+
const routeMod = await import('./src/app/api/logs/route')
|
|
13
|
+
const route = routeMod.default || routeMod
|
|
14
|
+
|
|
15
|
+
const postResponse = await route.POST(new Request('http://local/api/logs', {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'content-type': 'application/json' },
|
|
18
|
+
body: JSON.stringify({
|
|
19
|
+
source: 'error-boundary',
|
|
20
|
+
message: 'Client render failed',
|
|
21
|
+
componentStack: 'at DemoComponent',
|
|
22
|
+
}),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const getResponse = await route.GET(new Request('http://local/api/logs?lines=5&search=Client%20render%20failed'))
|
|
26
|
+
|
|
27
|
+
console.log(JSON.stringify({
|
|
28
|
+
status: postResponse.status,
|
|
29
|
+
payload: await postResponse.json(),
|
|
30
|
+
entries: (await getResponse.json()).entries,
|
|
31
|
+
}))
|
|
32
|
+
`, { prefix: 'swarmclaw-logs-route-' })
|
|
33
|
+
|
|
34
|
+
assert.equal(output.status, 200)
|
|
35
|
+
assert.equal(output.payload.ok, true)
|
|
36
|
+
assert.ok(output.entries.some((entry) => entry.tag === 'error-boundary' && entry.message === 'Client render failed'))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('logs route rejects malformed client error payloads with a 400', () => {
|
|
40
|
+
const output = runWithTempDataDir<{
|
|
41
|
+
status: number
|
|
42
|
+
payload: { error?: string }
|
|
43
|
+
}>(`
|
|
44
|
+
const routeMod = await import('./src/app/api/logs/route')
|
|
45
|
+
const route = routeMod.default || routeMod
|
|
46
|
+
|
|
47
|
+
const response = await route.POST(new Request('http://local/api/logs', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
body: '{bad-json',
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
console.log(JSON.stringify({
|
|
54
|
+
status: response.status,
|
|
55
|
+
payload: await response.json(),
|
|
56
|
+
}))
|
|
57
|
+
`, { prefix: 'swarmclaw-logs-route-' })
|
|
58
|
+
|
|
59
|
+
assert.equal(output.status, 400)
|
|
60
|
+
assert.equal(output.payload.error, 'Invalid or missing request body')
|
|
61
|
+
})
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import fs from 'fs'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
3
5
|
import { APP_LOG_PATH } from '@/lib/server/data-dir'
|
|
6
|
+
import { log } from '@/lib/server/logger'
|
|
7
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
4
8
|
|
|
5
9
|
/** Max bytes to read from the tail of the log file (256 KB). */
|
|
6
10
|
const TAIL_BYTES = 256 * 1024
|
|
7
11
|
|
|
12
|
+
const ClientLogSchema = z.object({
|
|
13
|
+
source: z.string().trim().min(1).max(120).optional().default('client'),
|
|
14
|
+
message: z.string().trim().min(1).max(1000),
|
|
15
|
+
stack: z.string().max(8000).optional(),
|
|
16
|
+
componentStack: z.string().max(8000).optional(),
|
|
17
|
+
digest: z.string().max(200).optional(),
|
|
18
|
+
url: z.string().max(2000).optional(),
|
|
19
|
+
pathname: z.string().max(1000).optional(),
|
|
20
|
+
userAgent: z.string().max(1000).optional(),
|
|
21
|
+
})
|
|
22
|
+
|
|
8
23
|
export async function GET(req: Request) {
|
|
9
24
|
const { searchParams } = new URL(req.url)
|
|
10
25
|
const lines = parseInt(searchParams.get('lines') || '200', 10)
|
|
@@ -76,6 +91,26 @@ export async function DELETE() {
|
|
|
76
91
|
}
|
|
77
92
|
}
|
|
78
93
|
|
|
94
|
+
export async function POST(req: Request) {
|
|
95
|
+
const { data: body, error } = await safeParseBody(req, ClientLogSchema)
|
|
96
|
+
if (error) return error
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
log.error(body.source, body.message, {
|
|
100
|
+
stack: body.stack,
|
|
101
|
+
componentStack: body.componentStack,
|
|
102
|
+
digest: body.digest,
|
|
103
|
+
url: body.url,
|
|
104
|
+
pathname: body.pathname,
|
|
105
|
+
userAgent: body.userAgent,
|
|
106
|
+
})
|
|
107
|
+
return NextResponse.json({ ok: true })
|
|
108
|
+
} catch (err: unknown) {
|
|
109
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
110
|
+
return NextResponse.json({ error: message }, { status: 500 })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
79
114
|
function parseLine(line: string) {
|
|
80
115
|
// Format: [2026-02-19T17:06:00.000Z] [INFO] [tag] message | data
|
|
81
116
|
const match = line.match(/^\[([^\]]+)\]\s+\[(\w+)\]\s+\[([^\]]+)\]\s+(.*)$/)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
export const dynamic = 'force-dynamic'
|
|
4
|
+
|
|
5
|
+
const API_URL = process.env.SWARMDOCK_API_URL || 'https://swarmdock-api.onrender.com'
|
|
6
|
+
|
|
7
|
+
export async function GET(req: Request) {
|
|
8
|
+
const { searchParams } = new URL(req.url)
|
|
9
|
+
const type = searchParams.get('type') || 'tasks'
|
|
10
|
+
const limit = searchParams.get('limit') || '50'
|
|
11
|
+
|
|
12
|
+
const endpoint = type === 'agents' ? '/api/v1/agents' : '/api/v1/tasks'
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(`${API_URL}${endpoint}?limit=${limit}`)
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = await res.text().catch(() => 'Unknown error')
|
|
17
|
+
return NextResponse.json({ error: `SwarmDock API error ${res.status}: ${text}` }, { status: 502 })
|
|
18
|
+
}
|
|
19
|
+
const data = await res.json()
|
|
20
|
+
return NextResponse.json(data)
|
|
21
|
+
} catch (err: unknown) {
|
|
22
|
+
const message = err instanceof Error ? err.message : 'Failed to fetch'
|
|
23
|
+
return NextResponse.json({ error: message }, { status: 502 })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { createPost, getFeed } from '@/lib/swarmfeed-client'
|
|
3
|
-
import { getAgent } from '@/lib/server/agents/agent-repository'
|
|
2
|
+
import { createPost, getFeed, registerAgent } from '@/lib/swarmfeed-client'
|
|
3
|
+
import { getAgent, patchAgent } from '@/lib/server/agents/agent-repository'
|
|
4
4
|
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
5
|
+
import { log } from '@/lib/server/logger'
|
|
5
6
|
import type { Agent } from '@/types'
|
|
6
7
|
|
|
7
8
|
export const dynamic = 'force-dynamic'
|
|
@@ -37,15 +38,52 @@ export async function POST(req: Request) {
|
|
|
37
38
|
return NextResponse.json({ error: 'content is required' }, { status: 400 })
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
// Look up the agent
|
|
41
|
-
|
|
42
|
-
if (!agent
|
|
41
|
+
// Look up the agent and auto-register on SwarmFeed if needed
|
|
42
|
+
let agent = getAgent(body.agentId) as Agent | undefined
|
|
43
|
+
if (!agent) {
|
|
44
|
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
|
45
|
+
}
|
|
46
|
+
if (!agent.swarmfeedEnabled) {
|
|
43
47
|
return NextResponse.json(
|
|
44
|
-
{ error: '
|
|
48
|
+
{ error: 'SwarmFeed is not enabled for this agent. Enable it in agent settings first.' },
|
|
45
49
|
{ status: 400 },
|
|
46
50
|
)
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
// Auto-register if enabled but no API key yet
|
|
54
|
+
if (!agent.swarmfeedApiKey) {
|
|
55
|
+
const agentName = agent.name
|
|
56
|
+
try {
|
|
57
|
+
log.info('swarmfeed', `Auto-registering agent "${agentName}" on SwarmFeed`)
|
|
58
|
+
const reg = await registerAgent({
|
|
59
|
+
name: agent.name,
|
|
60
|
+
description: agent.description || agent.swarmfeedBio || `${agent.name} agent on SwarmClaw`,
|
|
61
|
+
framework: 'swarmclaw',
|
|
62
|
+
model: agent.model,
|
|
63
|
+
avatar: agent.avatarUrl || undefined,
|
|
64
|
+
bio: agent.swarmfeedBio || undefined,
|
|
65
|
+
})
|
|
66
|
+
patchAgent(agent.id, (current) => {
|
|
67
|
+
if (!current) return null
|
|
68
|
+
return {
|
|
69
|
+
...current,
|
|
70
|
+
swarmfeedApiKey: reg.apiKey,
|
|
71
|
+
swarmfeedAgentId: reg.agentId,
|
|
72
|
+
swarmfeedJoinedAt: current.swarmfeedJoinedAt ?? Date.now(),
|
|
73
|
+
updatedAt: Date.now(),
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
agent = getAgent(body.agentId) as Agent | undefined
|
|
77
|
+
if (!agent?.swarmfeedApiKey) {
|
|
78
|
+
return NextResponse.json({ error: 'Registration succeeded but API key not saved' }, { status: 500 })
|
|
79
|
+
}
|
|
80
|
+
} catch (err: unknown) {
|
|
81
|
+
const message = err instanceof Error ? err.message : 'Registration failed'
|
|
82
|
+
log.error('swarmfeed', `Auto-registration failed for "${agentName}": ${message}`)
|
|
83
|
+
return NextResponse.json({ error: `SwarmFeed registration failed: ${message}` }, { status: 502 })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
49
87
|
try {
|
|
50
88
|
const post = await createPost(agent.swarmfeedApiKey, {
|
|
51
89
|
content: body.content.trim(),
|
|
@@ -25,6 +25,10 @@ export async function GET(req: Request) {
|
|
|
25
25
|
const agents = Object.values(loadAgents()) as Agent[]
|
|
26
26
|
const feedAgent = agents.find((a) => a.swarmfeedEnabled && a.swarmfeedApiKey)
|
|
27
27
|
agentApiKey = feedAgent?.swarmfeedApiKey ?? undefined
|
|
28
|
+
// No registered agent — return empty feed instead of triggering a 401
|
|
29
|
+
if (!agentApiKey) {
|
|
30
|
+
return NextResponse.json({ posts: [], nextCursor: undefined })
|
|
31
|
+
}
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
try {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('tts routes reject malformed JSON with a 400', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
ttsStatus: number
|
|
9
|
+
ttsPayload: { error?: string }
|
|
10
|
+
streamStatus: number
|
|
11
|
+
streamPayload: { error?: string }
|
|
12
|
+
}>(`
|
|
13
|
+
const ttsRouteMod = await import('./src/app/api/tts/route')
|
|
14
|
+
const ttsStreamRouteMod = await import('./src/app/api/tts/stream/route')
|
|
15
|
+
const ttsRoute = ttsRouteMod.default || ttsRouteMod
|
|
16
|
+
const ttsStreamRoute = ttsStreamRouteMod.default || ttsStreamRouteMod
|
|
17
|
+
|
|
18
|
+
const ttsResponse = await ttsRoute.POST(new Request('http://local/api/tts', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'content-type': 'application/json' },
|
|
21
|
+
body: '{bad-json',
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const ttsStreamResponse = await ttsStreamRoute.POST(new Request('http://local/api/tts/stream', {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'content-type': 'application/json' },
|
|
27
|
+
body: '{bad-json',
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
console.log(JSON.stringify({
|
|
31
|
+
ttsStatus: ttsResponse.status,
|
|
32
|
+
ttsPayload: await ttsResponse.json(),
|
|
33
|
+
streamStatus: ttsStreamResponse.status,
|
|
34
|
+
streamPayload: await ttsStreamResponse.json(),
|
|
35
|
+
}))
|
|
36
|
+
`, { prefix: 'swarmclaw-tts-route-' })
|
|
37
|
+
|
|
38
|
+
assert.equal(output.ttsStatus, 400)
|
|
39
|
+
assert.equal(output.ttsPayload.error, 'Invalid or missing request body')
|
|
40
|
+
assert.equal(output.streamStatus, 400)
|
|
41
|
+
assert.equal(output.streamPayload.error, 'Invalid or missing request body')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('tts routes reject empty text with a validation error', () => {
|
|
45
|
+
const output = runWithTempDataDir<{
|
|
46
|
+
ttsStatus: number
|
|
47
|
+
ttsPayload: { error?: string; issues?: Array<{ path: string; message: string }> }
|
|
48
|
+
streamStatus: number
|
|
49
|
+
streamPayload: { error?: string; issues?: Array<{ path: string; message: string }> }
|
|
50
|
+
}>(`
|
|
51
|
+
const ttsRouteMod = await import('./src/app/api/tts/route')
|
|
52
|
+
const ttsStreamRouteMod = await import('./src/app/api/tts/stream/route')
|
|
53
|
+
const ttsRoute = ttsRouteMod.default || ttsRouteMod
|
|
54
|
+
const ttsStreamRoute = ttsStreamRouteMod.default || ttsStreamRouteMod
|
|
55
|
+
|
|
56
|
+
const ttsResponse = await ttsRoute.POST(new Request('http://local/api/tts', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ text: ' ' }),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
const ttsStreamResponse = await ttsStreamRoute.POST(new Request('http://local/api/tts/stream', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ text: ' ' }),
|
|
66
|
+
}))
|
|
67
|
+
|
|
68
|
+
console.log(JSON.stringify({
|
|
69
|
+
ttsStatus: ttsResponse.status,
|
|
70
|
+
ttsPayload: await ttsResponse.json(),
|
|
71
|
+
streamStatus: ttsStreamResponse.status,
|
|
72
|
+
streamPayload: await ttsStreamResponse.json(),
|
|
73
|
+
}))
|
|
74
|
+
`, { prefix: 'swarmclaw-tts-route-' })
|
|
75
|
+
|
|
76
|
+
assert.equal(output.ttsStatus, 400)
|
|
77
|
+
assert.equal(output.ttsPayload.error, 'Validation failed')
|
|
78
|
+
assert.deepEqual(output.ttsPayload.issues, [{ path: 'text', message: 'No text provided' }])
|
|
79
|
+
assert.equal(output.streamStatus, 400)
|
|
80
|
+
assert.equal(output.streamPayload.error, 'Validation failed')
|
|
81
|
+
assert.deepEqual(output.streamPayload.issues, [{ path: 'text', message: 'No text provided' }])
|
|
82
|
+
})
|
package/src/app/api/tts/route.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
2
4
|
import { explainElevenLabsError, resolveElevenLabsConfig, synthesizeElevenLabsMp3 } from '@/lib/server/elevenlabs'
|
|
5
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
6
|
+
|
|
7
|
+
const TtsRequestSchema = z.object({
|
|
8
|
+
text: z.string().trim().min(1, 'No text provided'),
|
|
9
|
+
voiceId: z.string().nullable().optional(),
|
|
10
|
+
})
|
|
3
11
|
|
|
4
12
|
export async function POST(req: Request) {
|
|
13
|
+
const { data: body, error } = await safeParseBody(req, TtsRequestSchema)
|
|
14
|
+
if (error) return error
|
|
15
|
+
|
|
5
16
|
try {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return new NextResponse('No text provided', { status: 400 })
|
|
9
|
-
}
|
|
10
|
-
resolveElevenLabsConfig(voiceId)
|
|
11
|
-
const audioBuffer = await synthesizeElevenLabsMp3({ text: String(text || ''), voiceId })
|
|
17
|
+
resolveElevenLabsConfig(body.voiceId)
|
|
18
|
+
const audioBuffer = await synthesizeElevenLabsMp3({ text: body.text, voiceId: body.voiceId })
|
|
12
19
|
return new NextResponse(new Uint8Array(audioBuffer), {
|
|
13
20
|
headers: {
|
|
14
21
|
'Content-Type': 'audio/mpeg',
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
1
3
|
import { explainElevenLabsError, requestElevenLabsMp3Stream } from '@/lib/server/elevenlabs'
|
|
4
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
5
|
+
|
|
6
|
+
const TtsStreamRequestSchema = z.object({
|
|
7
|
+
text: z.string().trim().min(1, 'No text provided'),
|
|
8
|
+
voiceId: z.string().nullable().optional(),
|
|
9
|
+
})
|
|
2
10
|
|
|
3
11
|
export async function POST(req: Request) {
|
|
12
|
+
const { data: body, error } = await safeParseBody(req, TtsStreamRequestSchema)
|
|
13
|
+
if (error) return error
|
|
14
|
+
|
|
4
15
|
try {
|
|
5
|
-
const { text, voiceId }
|
|
6
|
-
if (!String(text || '').trim()) {
|
|
7
|
-
return new Response('No text provided', { status: 400 })
|
|
8
|
-
}
|
|
9
|
-
const apiRes = await requestElevenLabsMp3Stream({ text: String(text || ''), voiceId })
|
|
16
|
+
const apiRes = await requestElevenLabsMp3Stream({ text: body.text, voiceId: body.voiceId })
|
|
10
17
|
return new Response(apiRes.body, {
|
|
11
18
|
headers: {
|
|
12
19
|
'Content-Type': 'audio/mpeg',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
import { ErrorFallback } from '@/components/layout/error-fallback'
|
|
6
|
+
import { reportClientError } from '@/lib/app/report-client-error'
|
|
7
|
+
|
|
8
|
+
export default function AppError({
|
|
9
|
+
error,
|
|
10
|
+
reset,
|
|
11
|
+
}: {
|
|
12
|
+
error: Error & { digest?: string }
|
|
13
|
+
reset: () => void
|
|
14
|
+
}) {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
reportClientError({
|
|
17
|
+
source: 'app-error',
|
|
18
|
+
error,
|
|
19
|
+
digest: error.digest,
|
|
20
|
+
})
|
|
21
|
+
}, [error])
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<ErrorFallback
|
|
25
|
+
message="A route-level error interrupted the current view. Try the request again or reload the app."
|
|
26
|
+
primaryLabel="Try Again"
|
|
27
|
+
onPrimaryAction={() => reset()}
|
|
28
|
+
secondaryLabel="Reload"
|
|
29
|
+
onSecondaryAction={() => window.location.reload()}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
import './globals.css'
|
|
6
|
+
|
|
7
|
+
import { ErrorFallback } from '@/components/layout/error-fallback'
|
|
8
|
+
import { reportClientError } from '@/lib/app/report-client-error'
|
|
9
|
+
|
|
10
|
+
export default function GlobalError({
|
|
11
|
+
error,
|
|
12
|
+
}: {
|
|
13
|
+
error: Error & { digest?: string }
|
|
14
|
+
}) {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
reportClientError({
|
|
17
|
+
source: 'global-error',
|
|
18
|
+
error,
|
|
19
|
+
digest: error.digest,
|
|
20
|
+
})
|
|
21
|
+
}, [error])
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<html lang="en" className="dark">
|
|
25
|
+
<body className="antialiased">
|
|
26
|
+
<ErrorFallback
|
|
27
|
+
message="A fatal application error occurred before the normal shell could recover. Reload the app to continue."
|
|
28
|
+
onPrimaryAction={() => window.location.reload()}
|
|
29
|
+
/>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
32
|
+
)
|
|
33
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -298,6 +298,9 @@ const COMMAND_GROUPS = [
|
|
|
298
298
|
commands: [
|
|
299
299
|
cmd('list', 'GET', '/logs', 'List logs (use --query lines=200, --query level=INFO,ERROR)'),
|
|
300
300
|
cmd('clear', 'DELETE', '/logs', 'Clear logs file'),
|
|
301
|
+
cmd('report', 'POST', '/logs', 'Write a client/browser error entry to the application log', {
|
|
302
|
+
expectsJsonBody: true,
|
|
303
|
+
}),
|
|
301
304
|
],
|
|
302
305
|
},
|
|
303
306
|
{
|
|
@@ -803,6 +806,13 @@ const COMMAND_GROUPS = [
|
|
|
803
806
|
cmd('post', 'POST', '/swarmfeed/posts', 'Create a post', { expectsJsonBody: true }),
|
|
804
807
|
],
|
|
805
808
|
},
|
|
809
|
+
{
|
|
810
|
+
name: 'swarmdock',
|
|
811
|
+
description: 'SwarmDock marketplace',
|
|
812
|
+
commands: [
|
|
813
|
+
cmd('browse', 'GET', '/swarmdock', 'Browse SwarmDock marketplace tasks and agents'),
|
|
814
|
+
],
|
|
815
|
+
},
|
|
806
816
|
]
|
|
807
817
|
|
|
808
818
|
const GROUP_MAP = new Map(COMMAND_GROUPS.map((group) => [group.name, group]))
|