@swarmclawai/swarmclaw 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -73
- 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 +74 -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/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/cli/index.js +3 -0
- package/src/cli/spec.js +1 -0
- package/src/components/layout/error-boundary.tsx +12 -30
- package/src/components/layout/error-fallback.tsx +61 -0
- package/src/features/swarmfeed/queries.ts +3 -3
- package/src/lib/app/report-client-error.ts +52 -0
- 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/chat-execution/chat-turn-preparation.ts +19 -12
- package/src/lib/server/connectors/swarmdock.ts +1 -1
- 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/storage.ts +13 -4
- package/src/lib/swarmfeed-client.ts +1 -1
- package/tsconfig.json +1 -2
- package/src/.env.local +0 -4
|
@@ -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,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
|
{
|
package/src/cli/spec.js
CHANGED
|
@@ -215,6 +215,7 @@ const COMMAND_GROUPS = {
|
|
|
215
215
|
commands: {
|
|
216
216
|
list: { description: 'Fetch logs (supports --query lines=200,level=INFO)', method: 'GET', path: '/logs' },
|
|
217
217
|
clear: { description: 'Clear log file', method: 'DELETE', path: '/logs' },
|
|
218
|
+
report: { description: 'Write a client/browser error entry to the application log', method: 'POST', path: '/logs' },
|
|
218
219
|
},
|
|
219
220
|
},
|
|
220
221
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import { Component } from 'react'
|
|
4
4
|
import type { ReactNode, ErrorInfo } from 'react'
|
|
5
5
|
|
|
6
|
+
import { reportClientError } from '@/lib/app/report-client-error'
|
|
7
|
+
import { ErrorFallback } from '@/components/layout/error-fallback'
|
|
8
|
+
|
|
6
9
|
export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
|
|
7
10
|
constructor(props: { children: ReactNode }) {
|
|
8
11
|
super(props)
|
|
@@ -15,41 +18,20 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError
|
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
18
|
-
|
|
21
|
+
reportClientError({
|
|
22
|
+
source: 'error-boundary',
|
|
23
|
+
error,
|
|
24
|
+
componentStack: info.componentStack,
|
|
25
|
+
})
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
render() {
|
|
22
29
|
if (this.state.hasError) {
|
|
23
30
|
return (
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<circle cx="12" cy="12" r="10" />
|
|
29
|
-
<line x1="12" y1="8" x2="12" y2="12" />
|
|
30
|
-
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
31
|
-
</svg>
|
|
32
|
-
</div>
|
|
33
|
-
<h2 className="font-display text-[22px] font-700 text-text mb-2 tracking-[-0.02em]">
|
|
34
|
-
Something went wrong
|
|
35
|
-
</h2>
|
|
36
|
-
<p className="text-[14px] text-text-3 mb-6">
|
|
37
|
-
An unexpected error occurred. Try reloading the page.
|
|
38
|
-
</p>
|
|
39
|
-
<button
|
|
40
|
-
onClick={() => window.location.reload()}
|
|
41
|
-
className="inline-flex items-center gap-2 px-6 py-3 rounded-[12px] border-none bg-accent-bright text-white text-[14px] font-600 cursor-pointer
|
|
42
|
-
hover:brightness-110 active:scale-[0.97] transition-all shadow-[0_4px_16px_rgba(99,102,241,0.2)]"
|
|
43
|
-
style={{ fontFamily: 'inherit' }}
|
|
44
|
-
>
|
|
45
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
46
|
-
<polyline points="23 4 23 10 17 10" />
|
|
47
|
-
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
48
|
-
</svg>
|
|
49
|
-
Reload
|
|
50
|
-
</button>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
31
|
+
<ErrorFallback
|
|
32
|
+
message="An unexpected dashboard error occurred. Reload the page to recover."
|
|
33
|
+
onPrimaryAction={() => window.location.reload()}
|
|
34
|
+
/>
|
|
53
35
|
)
|
|
54
36
|
}
|
|
55
37
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
type ErrorFallbackProps = {
|
|
4
|
+
title?: string
|
|
5
|
+
message?: string
|
|
6
|
+
primaryLabel?: string
|
|
7
|
+
onPrimaryAction?: () => void
|
|
8
|
+
secondaryLabel?: string
|
|
9
|
+
onSecondaryAction?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ErrorFallback({
|
|
13
|
+
title = 'Something went wrong',
|
|
14
|
+
message = 'An unexpected error occurred. Try again or reload the page.',
|
|
15
|
+
primaryLabel = 'Reload',
|
|
16
|
+
onPrimaryAction,
|
|
17
|
+
secondaryLabel,
|
|
18
|
+
onSecondaryAction,
|
|
19
|
+
}: ErrorFallbackProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex min-h-[50vh] flex-1 flex-col items-center justify-center bg-bg px-8">
|
|
22
|
+
<div className="max-w-[420px] text-center">
|
|
23
|
+
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-[16px] border border-red-500/20 bg-red-500/10">
|
|
24
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-red-400">
|
|
25
|
+
<circle cx="12" cy="12" r="10" />
|
|
26
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
27
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
28
|
+
</svg>
|
|
29
|
+
</div>
|
|
30
|
+
<h2 className="mb-2 font-display text-[22px] font-700 tracking-[-0.02em] text-text">
|
|
31
|
+
{title}
|
|
32
|
+
</h2>
|
|
33
|
+
<p className="mb-6 text-[14px] text-text-3">
|
|
34
|
+
{message}
|
|
35
|
+
</p>
|
|
36
|
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
37
|
+
<button
|
|
38
|
+
onClick={onPrimaryAction}
|
|
39
|
+
className="inline-flex cursor-pointer items-center gap-2 rounded-[12px] border-none bg-accent-bright px-6 py-3 text-[14px] font-600 text-white shadow-[0_4px_16px_rgba(99,102,241,0.2)] transition-all hover:brightness-110 active:scale-[0.97]"
|
|
40
|
+
style={{ fontFamily: 'inherit' }}
|
|
41
|
+
>
|
|
42
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
43
|
+
<polyline points="23 4 23 10 17 10" />
|
|
44
|
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
45
|
+
</svg>
|
|
46
|
+
{primaryLabel}
|
|
47
|
+
</button>
|
|
48
|
+
{secondaryLabel && onSecondaryAction ? (
|
|
49
|
+
<button
|
|
50
|
+
onClick={onSecondaryAction}
|
|
51
|
+
className="inline-flex cursor-pointer items-center rounded-[12px] border border-border bg-transparent px-5 py-3 text-[14px] font-600 text-text transition-colors hover:bg-panel/60"
|
|
52
|
+
style={{ fontFamily: 'inherit' }}
|
|
53
|
+
>
|
|
54
|
+
{secondaryLabel}
|
|
55
|
+
</button>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -10,16 +10,16 @@ export async function fetchFeed(
|
|
|
10
10
|
if (params?.channelId) searchParams.set('channelId', params.channelId)
|
|
11
11
|
if (params?.cursor) searchParams.set('cursor', params.cursor)
|
|
12
12
|
if (params?.limit) searchParams.set('limit', String(params.limit))
|
|
13
|
-
return api<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/
|
|
13
|
+
return api<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/swarmfeed?${searchParams.toString()}`)
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export async function fetchChannels(): Promise<SwarmFeedChannel[]> {
|
|
17
|
-
const result = await api<{ channels: SwarmFeedChannel[] }>('GET', '/
|
|
17
|
+
const result = await api<{ channels: SwarmFeedChannel[] }>('GET', '/swarmfeed/channels')
|
|
18
18
|
return result.channels
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function submitPost(agentId: string, content: string, channelId?: string, parentId?: string): Promise<SwarmFeedPost> {
|
|
22
|
-
return api<SwarmFeedPost>('POST', '/
|
|
22
|
+
return api<SwarmFeedPost>('POST', '/swarmfeed/posts', {
|
|
23
23
|
agentId,
|
|
24
24
|
content,
|
|
25
25
|
channelId,
|