@swarmclawai/swarmclaw 1.3.6 → 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 +16 -52
- package/next.config.ts +9 -4
- package/package.json +18 -10
- 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/.well-known/agent-card/route.ts +46 -0
- package/src/app/api/a2a/route.ts +56 -0
- package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -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/[id]/deploy/route.ts +2 -2
- 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/openclaw/sync/route.ts +1 -1
- package/src/app/api/swarmfeed/channels/route.ts +14 -0
- package/src/app/api/swarmfeed/posts/route.ts +60 -0
- package/src/app/api/swarmfeed/route.ts +37 -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/protocols/builder/[templateId]/page.tsx +93 -0
- package/src/app/protocols/page.tsx +16 -7
- package/src/app/swarmfeed/page.tsx +7 -0
- package/src/cli/index.js +22 -0
- package/src/cli/spec.js +9 -0
- package/src/components/agents/agent-avatar.tsx +2 -5
- package/src/components/agents/agent-sheet.tsx +10 -0
- package/src/components/auth/access-key-gate.tsx +25 -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 +52 -0
- package/src/components/protocols/builder/edge-editor.tsx +43 -0
- package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
- package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
- package/src/components/protocols/builder/edge-types/index.ts +3 -0
- package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
- package/src/components/protocols/builder/node-inspector.tsx +227 -0
- package/src/components/protocols/builder/node-palette.tsx +97 -0
- package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
- package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
- package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
- package/src/components/protocols/builder/node-types/index.ts +9 -0
- package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
- package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
- package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
- package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
- package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
- package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
- package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
- package/src/components/protocols/builder/run-overlay.tsx +29 -0
- package/src/components/protocols/builder/template-gallery.tsx +53 -0
- package/src/components/protocols/builder/validation-panel.tsx +57 -0
- package/src/components/skills/skills-workspace.tsx +1 -9
- package/src/features/protocols/builder/hooks/index.ts +2 -0
- package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
- package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
- package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
- package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
- package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
- package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
- package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
- package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
- package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
- package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
- package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
- package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
- package/src/features/swarmfeed/compose-post.tsx +139 -0
- package/src/features/swarmfeed/feed-page.tsx +136 -0
- package/src/features/swarmfeed/post-card.tsx +114 -0
- package/src/features/swarmfeed/queries.ts +28 -0
- package/src/lib/a2a/agent-card.ts +61 -0
- package/src/lib/a2a/auth.ts +54 -0
- package/src/lib/a2a/client.ts +133 -0
- package/src/lib/a2a/discovery.ts +116 -0
- package/src/lib/a2a/handlers.ts +176 -0
- package/src/lib/a2a/json-rpc-router.ts +38 -0
- package/src/lib/a2a/types.ts +95 -0
- 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 +119 -107
- package/src/lib/providers/ollama.ts +34 -14
- package/src/lib/providers/openai.ts +154 -142
- package/src/lib/providers/openclaw.ts +3 -3
- package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
- package/src/lib/server/agents/main-agent-loop.ts +377 -41
- package/src/lib/server/chat-execution/chat-execution.ts +12 -7
- 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/extensions.ts +11 -0
- package/src/lib/server/messages/message-repository.ts +31 -0
- package/src/lib/server/openclaw/sync.ts +4 -4
- package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
- package/src/lib/server/protocols/protocol-normalization.ts +1 -0
- package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
- package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
- package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
- package/src/lib/server/protocols/protocol-types.ts +1 -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/delegate.ts +151 -77
- package/src/lib/server/storage-auth.ts +10 -2
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/lib/server/storage.ts +113 -4
- package/src/lib/server/working-state/service.test.ts +2 -3
- package/src/lib/server/working-state/service.ts +37 -6
- package/src/lib/swarmfeed-client.ts +157 -0
- package/src/lib/validation/schemas.ts +1 -1
- package/src/stores/slices/data-slice.ts +3 -0
- package/src/stores/use-approval-store.ts +4 -1
- package/src/types/agent.ts +31 -1
- package/src/types/index.ts +1 -0
- package/src/types/protocol.ts +19 -0
- package/src/types/session.ts +1 -1
- package/src/types/swarmfeed.ts +30 -0
- package/tsconfig.json +1 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { BuilderNode, BuilderEdge, ValidationError, ValidationWarning } from '../protocol-builder-store'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BFS from startId through directed edges. Returns the set of reachable node IDs
|
|
5
|
+
* (including startId itself).
|
|
6
|
+
*/
|
|
7
|
+
export function getReachableNodes(
|
|
8
|
+
startId: string,
|
|
9
|
+
edges: BuilderEdge[],
|
|
10
|
+
allNodeIds: string[],
|
|
11
|
+
): Set<string> {
|
|
12
|
+
const nodeSet = new Set(allNodeIds)
|
|
13
|
+
const reachable = new Set<string>()
|
|
14
|
+
const queue: string[] = [startId]
|
|
15
|
+
reachable.add(startId)
|
|
16
|
+
|
|
17
|
+
while (queue.length > 0) {
|
|
18
|
+
const current = queue.shift()!
|
|
19
|
+
for (const edge of edges) {
|
|
20
|
+
if (edge.source === current) {
|
|
21
|
+
const target = edge.target
|
|
22
|
+
if (nodeSet.has(target) && !reachable.has(target)) {
|
|
23
|
+
reachable.add(target)
|
|
24
|
+
queue.push(target)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return reachable
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validates the DAG structure of a workflow graph.
|
|
35
|
+
* Returns errors (blocking issues) and warnings (informational).
|
|
36
|
+
*/
|
|
37
|
+
export function validateDAG(
|
|
38
|
+
nodes: BuilderNode[],
|
|
39
|
+
edges: BuilderEdge[],
|
|
40
|
+
): { errors: ValidationError[]; warnings: ValidationWarning[] } {
|
|
41
|
+
if (nodes.length === 0) {
|
|
42
|
+
return { errors: [], warnings: [] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const errors: ValidationError[] = []
|
|
46
|
+
const warnings: ValidationWarning[] = []
|
|
47
|
+
|
|
48
|
+
const allNodeIds = nodes.map((n) => n.id)
|
|
49
|
+
const entryNode = nodes[0]
|
|
50
|
+
|
|
51
|
+
// Build adjacency sets for quick lookup
|
|
52
|
+
const nodesWithAnyEdge = new Set<string>()
|
|
53
|
+
const nodesWithOutgoing = new Set<string>()
|
|
54
|
+
|
|
55
|
+
for (const edge of edges) {
|
|
56
|
+
nodesWithAnyEdge.add(edge.source)
|
|
57
|
+
nodesWithAnyEdge.add(edge.target)
|
|
58
|
+
nodesWithOutgoing.add(edge.source)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Orphan detection: nodes not connected to any edge
|
|
62
|
+
// Skip: the entry node (first node), nodes of kind 'complete'
|
|
63
|
+
const orphanNodeIds = new Set<string>()
|
|
64
|
+
for (const node of nodes) {
|
|
65
|
+
if (node.id === entryNode.id) continue
|
|
66
|
+
if (node.data.kind === 'complete') continue
|
|
67
|
+
if (!nodesWithAnyEdge.has(node.id)) {
|
|
68
|
+
orphanNodeIds.add(node.id)
|
|
69
|
+
errors.push({
|
|
70
|
+
nodeId: node.id,
|
|
71
|
+
message: `Node "${node.data.label}" is not connected to any edge`,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Unreachable detection: connected nodes not reachable from entry via BFS
|
|
77
|
+
// Don't double-flag orphans
|
|
78
|
+
const reachable = getReachableNodes(entryNode.id, edges, allNodeIds)
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
if (node.id === entryNode.id) continue
|
|
81
|
+
if (orphanNodeIds.has(node.id)) continue
|
|
82
|
+
if (!reachable.has(node.id)) {
|
|
83
|
+
errors.push({
|
|
84
|
+
nodeId: node.id,
|
|
85
|
+
message: `Node "${node.data.label}" is not reachable from the entry node`,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Branch validation: each branchCase must have a matching outgoing edge
|
|
91
|
+
for (const node of nodes) {
|
|
92
|
+
if (node.data.kind !== 'branch') continue
|
|
93
|
+
const cases = node.data.branchCases ?? []
|
|
94
|
+
for (const branchCase of cases) {
|
|
95
|
+
const hasEdge = edges.some(
|
|
96
|
+
(e) => e.source === node.id && e.data?.branchCaseId === branchCase.id,
|
|
97
|
+
)
|
|
98
|
+
if (!hasEdge) {
|
|
99
|
+
errors.push({
|
|
100
|
+
nodeId: node.id,
|
|
101
|
+
message: `Branch node "${node.data.label}" has case "${branchCase.label}" without a connected edge`,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Warnings: nodes with no outgoing edge (except 'complete' kind)
|
|
108
|
+
for (const node of nodes) {
|
|
109
|
+
if (node.data.kind === 'complete') continue
|
|
110
|
+
if (!nodesWithOutgoing.has(node.id)) {
|
|
111
|
+
warnings.push({
|
|
112
|
+
nodeId: node.id,
|
|
113
|
+
message: `Node "${node.data.label}" has no outgoing edge`,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { errors, warnings }
|
|
119
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
4
|
+
import { updateAgent } from '@/lib/agents'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { HintTip } from '@/components/shared/hint-tip'
|
|
7
|
+
import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
|
|
8
|
+
import { fetchChannels } from './queries'
|
|
9
|
+
import type { Agent, SwarmFeedHeartbeatConfig } from '@/types'
|
|
10
|
+
import type { SwarmFeedChannel } from '@/types/swarmfeed'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_HEARTBEAT: SwarmFeedHeartbeatConfig = {
|
|
13
|
+
enabled: false,
|
|
14
|
+
browseFeed: false,
|
|
15
|
+
postFrequency: 'manual_only',
|
|
16
|
+
autoReply: false,
|
|
17
|
+
autoFollow: false,
|
|
18
|
+
channelsToMonitor: [],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AgentSocialSettings({ agent, onUpdate }: {
|
|
22
|
+
agent: Agent
|
|
23
|
+
onUpdate?: (agent: Agent) => void
|
|
24
|
+
}) {
|
|
25
|
+
const [enabled, setEnabled] = useState(agent.swarmfeedEnabled || false)
|
|
26
|
+
const [bio, setBio] = useState(agent.swarmfeedBio || '')
|
|
27
|
+
const [autoPost, setAutoPost] = useState(agent.swarmfeedAutoPost || false)
|
|
28
|
+
const [autoPostChannels, setAutoPostChannels] = useState<string[]>(agent.swarmfeedAutoPostChannels || [])
|
|
29
|
+
const [heartbeat, setHeartbeat] = useState<SwarmFeedHeartbeatConfig>(agent.swarmfeedHeartbeat || DEFAULT_HEARTBEAT)
|
|
30
|
+
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
31
|
+
const [channels, setChannels] = useState<SwarmFeedChannel[]>([])
|
|
32
|
+
const [saving, setSaving] = useState(false)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
fetchChannels().then(setChannels).catch(() => { /* channels load is best-effort */ })
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
const handleSave = useCallback(async () => {
|
|
39
|
+
setSaving(true)
|
|
40
|
+
try {
|
|
41
|
+
const updated = await updateAgent(agent.id, {
|
|
42
|
+
swarmfeedEnabled: enabled,
|
|
43
|
+
swarmfeedBio: bio.trim() || null,
|
|
44
|
+
swarmfeedAutoPost: autoPost,
|
|
45
|
+
swarmfeedAutoPostChannels: autoPostChannels,
|
|
46
|
+
swarmfeedJoinedAt: enabled && !agent.swarmfeedJoinedAt ? Date.now() : agent.swarmfeedJoinedAt,
|
|
47
|
+
swarmfeedHeartbeat: heartbeat.enabled ? heartbeat : null,
|
|
48
|
+
})
|
|
49
|
+
toast.success('Social settings saved')
|
|
50
|
+
onUpdate?.(updated)
|
|
51
|
+
} catch {
|
|
52
|
+
toast.error('Failed to save social settings')
|
|
53
|
+
} finally {
|
|
54
|
+
setSaving(false)
|
|
55
|
+
}
|
|
56
|
+
}, [agent.id, agent.swarmfeedJoinedAt, enabled, bio, autoPost, autoPostChannels, heartbeat, onUpdate])
|
|
57
|
+
|
|
58
|
+
const toggleChannel = useCallback((channelId: string) => {
|
|
59
|
+
setAutoPostChannels((prev) =>
|
|
60
|
+
prev.includes(channelId) ? prev.filter((c) => c !== channelId) : [...prev, channelId],
|
|
61
|
+
)
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const toggleMonitorChannel = useCallback((channelId: string) => {
|
|
65
|
+
setHeartbeat((prev) => ({
|
|
66
|
+
...prev,
|
|
67
|
+
channelsToMonitor: prev.channelsToMonitor.includes(channelId)
|
|
68
|
+
? prev.channelsToMonitor.filter((c) => c !== channelId)
|
|
69
|
+
: [...prev.channelsToMonitor, channelId],
|
|
70
|
+
}))
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="space-y-5">
|
|
75
|
+
{/* Enable/Disable toggle */}
|
|
76
|
+
<div className="flex items-center justify-between gap-4 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4">
|
|
77
|
+
<div className="min-w-0">
|
|
78
|
+
<div className="flex items-center gap-2">
|
|
79
|
+
<p className="text-[14px] font-600 text-text">SwarmFeed</p>
|
|
80
|
+
<HintTip text="Enable this agent to participate in the SwarmFeed social network" />
|
|
81
|
+
</div>
|
|
82
|
+
<p className="mt-1 text-[12px] leading-[1.6] text-text-3/75">
|
|
83
|
+
Let this agent post, follow, and engage on the social feed.
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setEnabled((c) => !c)}
|
|
89
|
+
className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${enabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
|
|
90
|
+
aria-pressed={enabled}
|
|
91
|
+
>
|
|
92
|
+
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{enabled && (
|
|
97
|
+
<>
|
|
98
|
+
{/* Bio */}
|
|
99
|
+
<div>
|
|
100
|
+
<label className="flex items-center gap-2 text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
101
|
+
Bio <HintTip text="A short bio shown on the agent's social profile" />
|
|
102
|
+
</label>
|
|
103
|
+
<textarea
|
|
104
|
+
value={bio}
|
|
105
|
+
onChange={(e) => setBio(e.target.value)}
|
|
106
|
+
placeholder="A brief description of this agent for social..."
|
|
107
|
+
className="w-full min-h-[80px] px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[14px] outline-none transition-all placeholder:text-text-3/50 focus-glow resize-y"
|
|
108
|
+
style={{ fontFamily: 'inherit' }}
|
|
109
|
+
maxLength={500}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Auto-post toggle */}
|
|
114
|
+
<div className="flex items-center justify-between gap-4 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-3">
|
|
115
|
+
<div className="min-w-0">
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<p className="text-[13px] font-600 text-text">Auto-post</p>
|
|
118
|
+
<HintTip text="Automatically post updates from this agent's activity" />
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => setAutoPost((c) => !c)}
|
|
124
|
+
className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${autoPost ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
|
|
125
|
+
aria-pressed={autoPost}
|
|
126
|
+
>
|
|
127
|
+
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${autoPost ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Auto-post channels */}
|
|
132
|
+
{autoPost && channels.length > 0 && (
|
|
133
|
+
<div>
|
|
134
|
+
<label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
135
|
+
Auto-post Channels
|
|
136
|
+
</label>
|
|
137
|
+
<div className="flex flex-wrap gap-2">
|
|
138
|
+
{channels.map((ch) => (
|
|
139
|
+
<button
|
|
140
|
+
key={ch.id}
|
|
141
|
+
onClick={() => toggleChannel(ch.id)}
|
|
142
|
+
className={`px-3 py-1.5 rounded-[10px] border text-[12px] font-500 transition-all cursor-pointer bg-transparent
|
|
143
|
+
${autoPostChannels.includes(ch.id)
|
|
144
|
+
? 'border-accent-bright/40 bg-accent-bright/10 text-accent-bright'
|
|
145
|
+
: 'border-white/[0.08] text-text-3 hover:text-text hover:bg-white/[0.04]'
|
|
146
|
+
}`}
|
|
147
|
+
>
|
|
148
|
+
#{ch.handle}
|
|
149
|
+
</button>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{/* Advanced: Heartbeat config */}
|
|
156
|
+
<AdvancedSettingsSection
|
|
157
|
+
open={showAdvanced}
|
|
158
|
+
onToggle={() => setShowAdvanced((c) => !c)}
|
|
159
|
+
summary={heartbeat.enabled ? 'Active' : undefined}
|
|
160
|
+
badges={heartbeat.enabled ? [heartbeat.postFrequency.replace(/_/g, ' ')] : []}
|
|
161
|
+
>
|
|
162
|
+
<div className="space-y-4">
|
|
163
|
+
<div className="flex items-center justify-between gap-4">
|
|
164
|
+
<div className="min-w-0">
|
|
165
|
+
<div className="flex items-center gap-2">
|
|
166
|
+
<p className="text-[13px] font-600 text-text">Feed Heartbeat</p>
|
|
167
|
+
<HintTip text="When enabled, this agent will periodically browse and interact with the feed" />
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => setHeartbeat((h) => ({ ...h, enabled: !h.enabled }))}
|
|
173
|
+
className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${heartbeat.enabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
|
|
174
|
+
aria-pressed={heartbeat.enabled}
|
|
175
|
+
>
|
|
176
|
+
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${heartbeat.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{heartbeat.enabled && (
|
|
181
|
+
<>
|
|
182
|
+
<label className="flex items-center gap-3 cursor-pointer">
|
|
183
|
+
<div
|
|
184
|
+
onClick={() => setHeartbeat((h) => ({ ...h, browseFeed: !h.browseFeed }))}
|
|
185
|
+
className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${heartbeat.browseFeed ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
|
|
186
|
+
>
|
|
187
|
+
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${heartbeat.browseFeed ? 'left-[22px]' : 'left-0.5'}`} />
|
|
188
|
+
</div>
|
|
189
|
+
<span className="flex items-center gap-2 text-[13px] text-text-2">
|
|
190
|
+
Browse feed <HintTip text="Agent reads the feed during heartbeat cycles" />
|
|
191
|
+
</span>
|
|
192
|
+
</label>
|
|
193
|
+
|
|
194
|
+
<label className="flex items-center gap-3 cursor-pointer">
|
|
195
|
+
<div
|
|
196
|
+
onClick={() => setHeartbeat((h) => ({ ...h, autoReply: !h.autoReply }))}
|
|
197
|
+
className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${heartbeat.autoReply ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
|
|
198
|
+
>
|
|
199
|
+
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${heartbeat.autoReply ? 'left-[22px]' : 'left-0.5'}`} />
|
|
200
|
+
</div>
|
|
201
|
+
<span className="flex items-center gap-2 text-[13px] text-text-2">
|
|
202
|
+
Auto-reply <HintTip text="Automatically reply to mentions and interesting posts" />
|
|
203
|
+
</span>
|
|
204
|
+
</label>
|
|
205
|
+
|
|
206
|
+
<label className="flex items-center gap-3 cursor-pointer">
|
|
207
|
+
<div
|
|
208
|
+
onClick={() => setHeartbeat((h) => ({ ...h, autoFollow: !h.autoFollow }))}
|
|
209
|
+
className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${heartbeat.autoFollow ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
|
|
210
|
+
>
|
|
211
|
+
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${heartbeat.autoFollow ? 'left-[22px]' : 'left-0.5'}`} />
|
|
212
|
+
</div>
|
|
213
|
+
<span className="flex items-center gap-2 text-[13px] text-text-2">
|
|
214
|
+
Auto-follow <HintTip text="Automatically follow agents that share relevant content" />
|
|
215
|
+
</span>
|
|
216
|
+
</label>
|
|
217
|
+
|
|
218
|
+
<div>
|
|
219
|
+
<label className="flex items-center gap-2 text-[12px] font-600 text-text-2 mb-1.5">
|
|
220
|
+
Post frequency <HintTip text="How often the agent creates new posts during heartbeat cycles" />
|
|
221
|
+
</label>
|
|
222
|
+
<select
|
|
223
|
+
value={heartbeat.postFrequency}
|
|
224
|
+
onChange={(e) => setHeartbeat((h) => ({ ...h, postFrequency: e.target.value as SwarmFeedHeartbeatConfig['postFrequency'] }))}
|
|
225
|
+
className="w-full px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[14px] outline-none cursor-pointer"
|
|
226
|
+
style={{ fontFamily: 'inherit' }}
|
|
227
|
+
>
|
|
228
|
+
<option value="manual_only">Manual only</option>
|
|
229
|
+
<option value="every_cycle">Every cycle</option>
|
|
230
|
+
<option value="daily">Daily</option>
|
|
231
|
+
<option value="on_task_completion">On task completion</option>
|
|
232
|
+
</select>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{channels.length > 0 && (
|
|
236
|
+
<div>
|
|
237
|
+
<label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
238
|
+
Channels to Monitor
|
|
239
|
+
</label>
|
|
240
|
+
<div className="flex flex-wrap gap-2">
|
|
241
|
+
{channels.map((ch) => (
|
|
242
|
+
<button
|
|
243
|
+
key={ch.id}
|
|
244
|
+
onClick={() => toggleMonitorChannel(ch.id)}
|
|
245
|
+
className={`px-3 py-1.5 rounded-[10px] border text-[12px] font-500 transition-all cursor-pointer bg-transparent
|
|
246
|
+
${heartbeat.channelsToMonitor.includes(ch.id)
|
|
247
|
+
? 'border-accent-bright/40 bg-accent-bright/10 text-accent-bright'
|
|
248
|
+
: 'border-white/[0.08] text-text-3 hover:text-text hover:bg-white/[0.04]'
|
|
249
|
+
}`}
|
|
250
|
+
>
|
|
251
|
+
#{ch.handle}
|
|
252
|
+
</button>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</AdvancedSettingsSection>
|
|
261
|
+
</>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* Save button */}
|
|
265
|
+
<div className="flex justify-end pt-2">
|
|
266
|
+
<button
|
|
267
|
+
onClick={handleSave}
|
|
268
|
+
disabled={saving}
|
|
269
|
+
className="px-6 py-2.5 rounded-[12px] bg-accent-bright text-white text-[14px] font-600 transition-all
|
|
270
|
+
hover:bg-accent-bright/90 disabled:opacity-40 disabled:cursor-not-allowed border-none cursor-pointer"
|
|
271
|
+
>
|
|
272
|
+
{saving ? 'Saving...' : 'Save Social Settings'}
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
4
|
+
import { useAppStore } from '@/stores/use-app-store'
|
|
5
|
+
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
6
|
+
import { submitPost, fetchChannels } from './queries'
|
|
7
|
+
import { toast } from 'sonner'
|
|
8
|
+
import type { Agent } from '@/types'
|
|
9
|
+
import type { SwarmFeedChannel, SwarmFeedPost } from '@/types/swarmfeed'
|
|
10
|
+
|
|
11
|
+
export function ComposePost({ onPostCreated, onClose }: {
|
|
12
|
+
onPostCreated?: (post: SwarmFeedPost) => void
|
|
13
|
+
onClose?: () => void
|
|
14
|
+
}) {
|
|
15
|
+
const agents = useAppStore((s) => s.agents)
|
|
16
|
+
const feedAgents = Object.values(agents).filter(
|
|
17
|
+
(a: Agent) => a.swarmfeedEnabled && !a.disabled && !a.trashedAt,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const [selectedAgentId, setSelectedAgentId] = useState<string>(feedAgents[0]?.id || '')
|
|
21
|
+
const [content, setContent] = useState('')
|
|
22
|
+
const [channelId, setChannelId] = useState('')
|
|
23
|
+
const [channels, setChannels] = useState<SwarmFeedChannel[]>([])
|
|
24
|
+
const [posting, setPosting] = useState(false)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
fetchChannels().then(setChannels).catch(() => { /* channels are optional */ })
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
const handleSubmit = useCallback(async () => {
|
|
31
|
+
if (!selectedAgentId || !content.trim()) return
|
|
32
|
+
setPosting(true)
|
|
33
|
+
try {
|
|
34
|
+
const post = await submitPost(selectedAgentId, content.trim(), channelId || undefined)
|
|
35
|
+
toast.success('Post published')
|
|
36
|
+
setContent('')
|
|
37
|
+
onPostCreated?.(post)
|
|
38
|
+
onClose?.()
|
|
39
|
+
} catch {
|
|
40
|
+
toast.error('Failed to publish post')
|
|
41
|
+
} finally {
|
|
42
|
+
setPosting(false)
|
|
43
|
+
}
|
|
44
|
+
}, [selectedAgentId, content, channelId, onPostCreated, onClose])
|
|
45
|
+
|
|
46
|
+
const selectedAgent = selectedAgentId ? agents[selectedAgentId] : null
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="rounded-[20px] border border-white/[0.08] bg-surface p-5 sm:p-6">
|
|
50
|
+
<div className="flex items-center justify-between mb-4">
|
|
51
|
+
<h3 className="font-display text-[17px] font-700 tracking-[-0.02em] text-text">Compose Post</h3>
|
|
52
|
+
{onClose && (
|
|
53
|
+
<button
|
|
54
|
+
onClick={onClose}
|
|
55
|
+
className="w-8 h-8 rounded-[10px] flex items-center justify-center text-text-3 hover:text-text hover:bg-white/[0.06] transition-all bg-transparent border-none cursor-pointer"
|
|
56
|
+
>
|
|
57
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
58
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
59
|
+
</svg>
|
|
60
|
+
</button>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Agent picker */}
|
|
65
|
+
<div className="mb-4">
|
|
66
|
+
<label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
67
|
+
Post as
|
|
68
|
+
</label>
|
|
69
|
+
{feedAgents.length === 0 ? (
|
|
70
|
+
<p className="text-[13px] text-text-3/75">
|
|
71
|
+
No agents have SwarmFeed enabled. Enable it in an agent's settings first.
|
|
72
|
+
</p>
|
|
73
|
+
) : (
|
|
74
|
+
<div className="flex flex-wrap gap-2">
|
|
75
|
+
{feedAgents.map((agent) => (
|
|
76
|
+
<button
|
|
77
|
+
key={agent.id}
|
|
78
|
+
onClick={() => setSelectedAgentId(agent.id)}
|
|
79
|
+
className={`flex items-center gap-2 px-3 py-2 rounded-[12px] border text-[13px] font-500 transition-all cursor-pointer bg-transparent
|
|
80
|
+
${selectedAgentId === agent.id
|
|
81
|
+
? 'border-accent-bright/40 bg-accent-bright/10 text-accent-bright'
|
|
82
|
+
: 'border-white/[0.08] text-text-3 hover:text-text hover:bg-white/[0.04]'
|
|
83
|
+
}`}
|
|
84
|
+
>
|
|
85
|
+
<AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={22} />
|
|
86
|
+
<span className="truncate max-w-[120px]">{agent.name}</span>
|
|
87
|
+
</button>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Content */}
|
|
94
|
+
<div className="mb-4">
|
|
95
|
+
<textarea
|
|
96
|
+
value={content}
|
|
97
|
+
onChange={(e) => setContent(e.target.value)}
|
|
98
|
+
placeholder={selectedAgent ? `What's ${selectedAgent.name} thinking?` : 'Write something...'}
|
|
99
|
+
className="w-full min-h-[120px] px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow resize-y"
|
|
100
|
+
style={{ fontFamily: 'inherit' }}
|
|
101
|
+
maxLength={2000}
|
|
102
|
+
/>
|
|
103
|
+
<div className="mt-1 text-right text-[11px] text-text-3/40">{content.length}/2000</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Channel selector */}
|
|
107
|
+
{channels.length > 0 && (
|
|
108
|
+
<div className="mb-4">
|
|
109
|
+
<label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
110
|
+
Channel (optional)
|
|
111
|
+
</label>
|
|
112
|
+
<select
|
|
113
|
+
value={channelId}
|
|
114
|
+
onChange={(e) => setChannelId(e.target.value)}
|
|
115
|
+
className="w-full px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[14px] outline-none cursor-pointer"
|
|
116
|
+
style={{ fontFamily: 'inherit' }}
|
|
117
|
+
>
|
|
118
|
+
<option value="">No channel</option>
|
|
119
|
+
{channels.map((ch) => (
|
|
120
|
+
<option key={ch.id} value={ch.id}>#{ch.handle} - {ch.displayName}</option>
|
|
121
|
+
))}
|
|
122
|
+
</select>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{/* Submit */}
|
|
127
|
+
<div className="flex justify-end">
|
|
128
|
+
<button
|
|
129
|
+
onClick={handleSubmit}
|
|
130
|
+
disabled={posting || !selectedAgentId || !content.trim()}
|
|
131
|
+
className="px-6 py-2.5 rounded-[12px] bg-accent-bright text-white text-[14px] font-600 transition-all
|
|
132
|
+
hover:bg-accent-bright/90 disabled:opacity-40 disabled:cursor-not-allowed border-none cursor-pointer"
|
|
133
|
+
>
|
|
134
|
+
{posting ? 'Publishing...' : 'Publish'}
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import { fetchFeed } from './queries'
|
|
5
|
+
import { PostCard } from './post-card'
|
|
6
|
+
import { ComposePost } from './compose-post'
|
|
7
|
+
import { MainContent } from '@/components/layout/main-content'
|
|
8
|
+
import { PageLoader } from '@/components/ui/page-loader'
|
|
9
|
+
import type { SwarmFeedPost, FeedType } from '@/types/swarmfeed'
|
|
10
|
+
|
|
11
|
+
const FEED_TABS: { key: FeedType; label: string }[] = [
|
|
12
|
+
{ key: 'for_you', label: 'For You' },
|
|
13
|
+
{ key: 'following', label: 'Following' },
|
|
14
|
+
{ key: 'trending', label: 'Trending' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export function FeedPage() {
|
|
18
|
+
const [activeTab, setActiveTab] = useState<FeedType>('for_you')
|
|
19
|
+
const [posts, setPosts] = useState<SwarmFeedPost[]>([])
|
|
20
|
+
const [loading, setLoading] = useState(true)
|
|
21
|
+
const [error, setError] = useState<string | null>(null)
|
|
22
|
+
const [showCompose, setShowCompose] = useState(false)
|
|
23
|
+
|
|
24
|
+
const loadFeed = useCallback(async (type: FeedType) => {
|
|
25
|
+
setLoading(true)
|
|
26
|
+
setError(null)
|
|
27
|
+
try {
|
|
28
|
+
const result = await fetchFeed(type, { limit: 50 })
|
|
29
|
+
setPosts(result.posts)
|
|
30
|
+
} catch (err: unknown) {
|
|
31
|
+
const message = err instanceof Error ? err.message : 'Failed to load feed'
|
|
32
|
+
setError(message)
|
|
33
|
+
setPosts([])
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false)
|
|
36
|
+
}
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
void loadFeed(activeTab)
|
|
41
|
+
}, [activeTab, loadFeed])
|
|
42
|
+
|
|
43
|
+
const handleTabChange = (tab: FeedType) => {
|
|
44
|
+
setActiveTab(tab)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const handlePostCreated = (post: SwarmFeedPost) => {
|
|
48
|
+
setPosts((prev) => [post, ...prev])
|
|
49
|
+
setShowCompose(false)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<MainContent>
|
|
54
|
+
<div className="flex-1 overflow-y-auto overscroll-contain">
|
|
55
|
+
<div className="mx-auto max-w-2xl px-4 sm:px-6 py-8">
|
|
56
|
+
<div className="flex items-center justify-between mb-6">
|
|
57
|
+
<div>
|
|
58
|
+
<h1 className="font-display text-[22px] font-700 tracking-[-0.02em] text-text">Feed</h1>
|
|
59
|
+
<p className="mt-1 text-[13px] text-text-3/75">Social updates from your AI agents</p>
|
|
60
|
+
</div>
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => setShowCompose((c) => !c)}
|
|
63
|
+
className="px-4 py-2 rounded-[12px] bg-accent-bright text-white text-[13px] font-600 transition-all
|
|
64
|
+
hover:bg-accent-bright/90 border-none cursor-pointer flex items-center gap-2"
|
|
65
|
+
>
|
|
66
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
67
|
+
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
68
|
+
</svg>
|
|
69
|
+
Compose
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Compose area */}
|
|
74
|
+
{showCompose && (
|
|
75
|
+
<div className="mb-6">
|
|
76
|
+
<ComposePost
|
|
77
|
+
onPostCreated={handlePostCreated}
|
|
78
|
+
onClose={() => setShowCompose(false)}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{/* Tab bar */}
|
|
84
|
+
<div className="flex gap-1 mb-6 rounded-[14px] border border-white/[0.06] bg-surface/50 p-1">
|
|
85
|
+
{FEED_TABS.map((tab) => (
|
|
86
|
+
<button
|
|
87
|
+
key={tab.key}
|
|
88
|
+
onClick={() => handleTabChange(tab.key)}
|
|
89
|
+
className={`flex-1 px-4 py-2.5 rounded-[10px] text-[13px] font-600 transition-all border-none cursor-pointer
|
|
90
|
+
${activeTab === tab.key
|
|
91
|
+
? 'bg-accent-bright/15 text-accent-bright'
|
|
92
|
+
: 'bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04]'
|
|
93
|
+
}`}
|
|
94
|
+
>
|
|
95
|
+
{tab.label}
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Feed content */}
|
|
101
|
+
{loading ? (
|
|
102
|
+
<PageLoader />
|
|
103
|
+
) : error ? (
|
|
104
|
+
<div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-8 text-center">
|
|
105
|
+
<div className="text-[14px] text-text-3/75 mb-3">{error}</div>
|
|
106
|
+
<button
|
|
107
|
+
onClick={() => loadFeed(activeTab)}
|
|
108
|
+
className="px-4 py-2 rounded-[10px] bg-white/[0.06] text-text text-[13px] font-500 border-none cursor-pointer hover:bg-white/[0.1] transition-all"
|
|
109
|
+
>
|
|
110
|
+
Retry
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
) : posts.length === 0 ? (
|
|
114
|
+
<div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-8 text-center">
|
|
115
|
+
<div className="w-12 h-12 rounded-full bg-white/[0.04] flex items-center justify-center mx-auto mb-4">
|
|
116
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40">
|
|
117
|
+
<path d="M4 11a9 9 0 0 1 9 9" /><path d="M4 4a16 16 0 0 1 16 16" /><circle cx="5" cy="19" r="1" />
|
|
118
|
+
</svg>
|
|
119
|
+
</div>
|
|
120
|
+
<p className="text-[14px] font-600 text-text mb-1">No posts yet</p>
|
|
121
|
+
<p className="text-[13px] text-text-3/75">
|
|
122
|
+
Enable SwarmFeed on your agents and start composing posts.
|
|
123
|
+
</p>
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<div className="space-y-4">
|
|
127
|
+
{posts.map((post) => (
|
|
128
|
+
<PostCard key={post.id} post={post} />
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</MainContent>
|
|
135
|
+
)
|
|
136
|
+
}
|