@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.
Files changed (41) hide show
  1. package/README.md +6 -73
  2. package/next.config.ts +9 -4
  3. package/package.json +10 -8
  4. package/scripts/build-bootstrap-env.mjs +24 -0
  5. package/scripts/run-next-build.mjs +74 -0
  6. package/scripts/run-next-typegen.mjs +61 -0
  7. package/src/app/api/approvals/route.test.ts +29 -3
  8. package/src/app/api/approvals/route.ts +13 -7
  9. package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
  10. package/src/app/api/chats/[id]/chat/route.ts +24 -8
  11. package/src/app/api/chats/chat-route.test.ts +68 -0
  12. package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
  13. package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
  14. package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
  15. package/src/app/api/logs/route.test.ts +61 -0
  16. package/src/app/api/logs/route.ts +35 -0
  17. package/src/app/api/tts/route.test.ts +82 -0
  18. package/src/app/api/tts/route.ts +13 -6
  19. package/src/app/api/tts/stream/route.ts +12 -5
  20. package/src/app/error.tsx +32 -0
  21. package/src/app/global-error.tsx +33 -0
  22. package/src/cli/index.js +3 -0
  23. package/src/cli/spec.js +1 -0
  24. package/src/components/layout/error-boundary.tsx +12 -30
  25. package/src/components/layout/error-fallback.tsx +61 -0
  26. package/src/features/swarmfeed/queries.ts +3 -3
  27. package/src/lib/app/report-client-error.ts +52 -0
  28. package/src/lib/providers/anthropic.ts +9 -1
  29. package/src/lib/providers/ollama.ts +34 -14
  30. package/src/lib/providers/openai.ts +9 -1
  31. package/src/lib/providers/openclaw.ts +3 -3
  32. package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
  33. package/src/lib/server/connectors/swarmdock.ts +1 -1
  34. package/src/lib/server/messages/message-repository.ts +31 -0
  35. package/src/lib/server/provider-health.ts +19 -3
  36. package/src/lib/server/safe-parse-body.test.ts +32 -0
  37. package/src/lib/server/safe-parse-body.ts +20 -3
  38. package/src/lib/server/storage.ts +13 -4
  39. package/src/lib/swarmfeed-client.ts +1 -1
  40. package/tsconfig.json +1 -2
  41. 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.json().catch(() => ({})) as ConnectorDoctorPreviewInput
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
+ })
@@ -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
- const { text, voiceId } = await req.json()
7
- if (!String(text || '').trim()) {
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 } = await req.json()
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
- console.error('ErrorBoundary caught:', error, info)
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
- <div className="flex-1 flex flex-col items-center justify-center px-8 bg-bg">
25
- <div className="text-center max-w-[400px]">
26
- <div className="w-14 h-14 rounded-[16px] bg-red-500/10 border border-red-500/20 flex items-center justify-center mx-auto mb-5">
27
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-red-400">
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', `/api/swarmfeed?${searchParams.toString()}`)
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', '/api/swarmfeed/channels')
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', '/api/swarmfeed/posts', {
22
+ return api<SwarmFeedPost>('POST', '/swarmfeed/posts', {
23
23
  agentId,
24
24
  content,
25
25
  channelId,