@swarmclawai/swarmclaw 1.1.8 → 1.2.0
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 +17 -0
- package/next.config.ts +0 -1
- package/package.json +3 -3
- package/src/app/activity/loading.tsx +5 -0
- package/src/app/api/agents/[id]/thread/route.ts +2 -1
- package/src/app/api/logs/route.ts +32 -5
- package/src/app/home/loading.tsx +5 -0
- package/src/app/logs/loading.tsx +5 -0
- package/src/app/memory/loading.tsx +5 -0
- package/src/app/tasks/loading.tsx +5 -0
- package/src/components/agents/agent-list.tsx +7 -12
- package/src/components/chat/chat-area.tsx +3 -3
- package/src/components/chat/chat-list.tsx +13 -2
- package/src/components/layout/sidebar-rail.tsx +14 -6
- package/src/components/memory/memory-graph-view.tsx +120 -68
- package/src/components/org-chart/mini-chat-bubble.tsx +58 -16
- package/src/components/shared/command-palette.tsx +35 -20
- package/src/components/shared/notification-center.tsx +1 -1
- package/src/hooks/use-app-bootstrap.ts +2 -1
- package/src/instrumentation.ts +27 -0
- package/src/lib/providers/anthropic.ts +14 -8
- package/src/lib/providers/openai.ts +3 -3
- package/src/lib/server/agents/agent-thread-session.ts +20 -14
- package/src/lib/server/chat-execution/continuation-evaluator.ts +2 -2
- package/src/lib/server/chat-execution/message-classifier.ts +2 -1
- package/src/lib/server/chat-execution/stream-agent-chat.ts +4 -19
- package/src/lib/server/chat-execution/stream-continuation.ts +4 -3
- package/src/lib/server/llm-response-cache.ts +2 -1
- package/src/lib/server/playwright-proxy.mjs +25 -6
- package/src/lib/server/runtime/run-ledger.ts +4 -4
- package/src/lib/server/runtime/scheduler.ts +9 -6
- package/src/lib/server/session-tools/skill-runtime.ts +10 -1
- package/src/lib/server/storage-cache.ts +2 -0
- package/src/lib/server/storage.ts +30 -3
- package/src/lib/server/tasks/task-quality-gate.test.ts +1 -1
- package/src/lib/server/tasks/task-quality-gate.ts +1 -1
- package/src/stores/slices/data-slice.ts +11 -0
- package/src/stores/slices/session-slice.ts +3 -0
package/README.md
CHANGED
|
@@ -190,6 +190,23 @@ The building blocks are the same: **agents, tools, memory, delegation, schedules
|
|
|
190
190
|
|
|
191
191
|
## Release Notes
|
|
192
192
|
|
|
193
|
+
### v1.1.9 Highlights
|
|
194
|
+
|
|
195
|
+
- **Docker build stability**: limit Next.js page data workers to 1 in build mode to prevent `SQLITE_BUSY` contention.
|
|
196
|
+
- **Async file I/O in providers**: Anthropic and OpenAI providers now use `fs.promises` for non-blocking attachment reads.
|
|
197
|
+
- **Anthropic request timeout**: 60s timeout on Anthropic API requests prevents indefinite hangs.
|
|
198
|
+
- **Graceful crash handling**: instrumentation now catches EPIPE and suppresses expected LangGraph unhandled rejections.
|
|
199
|
+
- **Log tail optimization**: `/api/logs` reads only the last 256 KB instead of loading the entire log file.
|
|
200
|
+
- **Thread session fast path**: `ensureAgentThreadSession` uses single-row lookup instead of full table scan when `threadSessionId` is set.
|
|
201
|
+
- **Memory graph performance**: force-directed simulation writes to DOM imperatively instead of re-rendering React state per frame; stops when kinetic energy settles.
|
|
202
|
+
- **Reduced polling frequency**: chat area WS polling intervals relaxed (messages/runs 2s to 10s, browser 5s to 30s) to lower server load.
|
|
203
|
+
- **Chat list indexing**: connector lookup indexed by `agentId` for O(1) instead of O(n) per session filter.
|
|
204
|
+
- **Sidebar skill badges**: skill draft count displayed as a badge on the Skills nav item.
|
|
205
|
+
- **Route loading states**: added `loading.tsx` skeleton pages for activity, home, logs, memory, and tasks routes.
|
|
206
|
+
- **Command palette cleanup**: fixed missing `setOpen` dependencies and removed unused props.
|
|
207
|
+
- **Playwright proxy hardening**: improved stdio pipe handling for dev server restarts.
|
|
208
|
+
- **Scheduler and run ledger fixes**: improved scheduler reliability and run ledger state tracking.
|
|
209
|
+
|
|
193
210
|
### v1.1.8 Highlights
|
|
194
211
|
|
|
195
212
|
- **Agent live status**: real-time `/agents/:id/status` endpoint exposes goal, progress, and plan steps; org chart detail panel consumes it via `useAgentLiveStatus` hook.
|
package/next.config.ts
CHANGED
|
@@ -58,7 +58,6 @@ const nextConfig: NextConfig = {
|
|
|
58
58
|
root: PROJECT_ROOT,
|
|
59
59
|
},
|
|
60
60
|
experimental: {
|
|
61
|
-
turbopackFileSystemCacheForDev: false,
|
|
62
61
|
// Limit build workers to 1 inside Docker to avoid SQLITE_BUSY contention
|
|
63
62
|
// when multiple workers collect page data concurrently.
|
|
64
63
|
...(process.env.SWARMCLAW_BUILD_MODE ? { cpus: 1 } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"langchain": "^1.2.30",
|
|
105
105
|
"lucide-react": "^0.574.0",
|
|
106
106
|
"mailparser": "^3.9.3",
|
|
107
|
-
"next": "16.1.
|
|
107
|
+
"next": "16.1.7",
|
|
108
108
|
"next-themes": "^0.4.6",
|
|
109
109
|
"nodemailer": "^8.0.1",
|
|
110
110
|
"openclaw": "^2026.2.26",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
},
|
|
141
141
|
"devDependencies": {
|
|
142
142
|
"eslint": "^9",
|
|
143
|
-
"eslint-config-next": "16.1.
|
|
143
|
+
"eslint-config-next": "16.1.7",
|
|
144
144
|
"tsx": "^4.20.6"
|
|
145
145
|
},
|
|
146
146
|
"optionalDependencies": {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/agent-availability'
|
|
3
3
|
import { ensureAgentThreadSession } from '@/lib/server/agents/agent-thread-session'
|
|
4
|
+
import type { Agent } from '@/types'
|
|
4
5
|
import { loadAgents } from '@/lib/server/storage'
|
|
5
6
|
|
|
6
7
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
@@ -11,7 +12,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
11
12
|
if (!agent) {
|
|
12
13
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
|
13
14
|
}
|
|
14
|
-
const session = ensureAgentThreadSession(agentId, user)
|
|
15
|
+
const session = ensureAgentThreadSession(agentId, user, agent as Agent)
|
|
15
16
|
if (!session) {
|
|
16
17
|
if (isAgentDisabled(agent)) {
|
|
17
18
|
return NextResponse.json({ error: buildAgentDisabledMessage(agent, 'start new chats') }, { status: 409 })
|
|
@@ -2,6 +2,9 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import fs from 'fs'
|
|
3
3
|
import { APP_LOG_PATH } from '@/lib/server/data-dir'
|
|
4
4
|
|
|
5
|
+
/** Max bytes to read from the tail of the log file (256 KB). */
|
|
6
|
+
const TAIL_BYTES = 256 * 1024
|
|
7
|
+
|
|
5
8
|
export async function GET(req: Request) {
|
|
6
9
|
const { searchParams } = new URL(req.url)
|
|
7
10
|
const lines = parseInt(searchParams.get('lines') || '200', 10)
|
|
@@ -13,7 +16,29 @@ export async function GET(req: Request) {
|
|
|
13
16
|
return NextResponse.json({ entries: [], total: 0 })
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
const
|
|
19
|
+
const stat = fs.statSync(APP_LOG_PATH)
|
|
20
|
+
const fileSize = stat.size
|
|
21
|
+
if (fileSize === 0) {
|
|
22
|
+
return NextResponse.json({ entries: [], total: 0 })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Read only the tail of the file to avoid loading multi-MB logs into memory
|
|
26
|
+
const readSize = Math.min(fileSize, TAIL_BYTES)
|
|
27
|
+
const buf = Buffer.alloc(readSize)
|
|
28
|
+
const fd = fs.openSync(APP_LOG_PATH, 'r')
|
|
29
|
+
try {
|
|
30
|
+
fs.readSync(fd, buf, 0, readSize, fileSize - readSize)
|
|
31
|
+
} finally {
|
|
32
|
+
fs.closeSync(fd)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let content = buf.toString('utf8')
|
|
36
|
+
// If we didn't read from the start, drop the first partial line
|
|
37
|
+
if (readSize < fileSize) {
|
|
38
|
+
const firstNewline = content.indexOf('\n')
|
|
39
|
+
if (firstNewline >= 0) content = content.slice(firstNewline + 1)
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
let allLines = content.split('\n').filter(Boolean)
|
|
18
43
|
|
|
19
44
|
// Filter by level
|
|
@@ -33,8 +58,9 @@ export async function GET(req: Request) {
|
|
|
33
58
|
const entries = allLines.slice(-lines).reverse().map(parseLine)
|
|
34
59
|
|
|
35
60
|
return NextResponse.json({ entries, total })
|
|
36
|
-
} catch (err:
|
|
37
|
-
|
|
61
|
+
} catch (err: unknown) {
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
63
|
+
return NextResponse.json({ error: message }, { status: 500 })
|
|
38
64
|
}
|
|
39
65
|
}
|
|
40
66
|
|
|
@@ -44,8 +70,9 @@ export async function DELETE() {
|
|
|
44
70
|
fs.writeFileSync(APP_LOG_PATH, '')
|
|
45
71
|
}
|
|
46
72
|
return NextResponse.json({ ok: true })
|
|
47
|
-
} catch (err:
|
|
48
|
-
|
|
73
|
+
} catch (err: unknown) {
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
75
|
+
return NextResponse.json({ error: message }, { status: 500 })
|
|
49
76
|
}
|
|
50
77
|
}
|
|
51
78
|
|
|
@@ -60,27 +60,22 @@ export function AgentList({ inSidebar }: Props) {
|
|
|
60
60
|
return ids
|
|
61
61
|
}, [sessions])
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const id = setInterval(() => setNow(Date.now()), 60_000)
|
|
67
|
-
return () => clearInterval(id)
|
|
68
|
-
}, [])
|
|
69
|
-
|
|
70
|
-
// Agents that are "online": heartbeat enabled + tools, or recently active (within 30min)
|
|
71
|
-
const onlineAgentIds = useMemo(() => {
|
|
63
|
+
// Compute online agent IDs — derives from agents and sessions data changes.
|
|
64
|
+
// The 30-minute threshold uses a callback to avoid calling Date.now() during render.
|
|
65
|
+
const computeOnlineIds = useCallback(() => {
|
|
72
66
|
const ids = new Set<string>()
|
|
73
|
-
const recentThreshold = now - 30 * 60 * 1000
|
|
67
|
+
const recentThreshold = Date.now() - 30 * 60 * 1000
|
|
74
68
|
for (const a of Object.values(agents)) {
|
|
75
69
|
if (a.disabled === true) continue
|
|
76
70
|
if (a.heartbeatEnabled === true && getEnabledCapabilityIds(a).length > 0) { ids.add(a.id); continue }
|
|
77
|
-
// Check if any session for this agent was active in the last 30 minutes
|
|
78
71
|
for (const s of Object.values(sessions)) {
|
|
79
72
|
if (s.agentId === a.id && (s.lastActiveAt ?? 0) > recentThreshold) { ids.add(a.id); break }
|
|
80
73
|
}
|
|
81
74
|
}
|
|
82
75
|
return ids
|
|
83
|
-
}, [agents, sessions
|
|
76
|
+
}, [agents, sessions])
|
|
77
|
+
const [onlineAgentIds, setOnlineAgentIds] = useState(() => computeOnlineIds())
|
|
78
|
+
useEffect(() => { setOnlineAgentIds(computeOnlineIds()) }, [computeOnlineIds])
|
|
84
79
|
|
|
85
80
|
// Approval counts per agent
|
|
86
81
|
const approvalsByAgent = useMemo(() => {
|
|
@@ -311,12 +311,12 @@ export function ChatArea() {
|
|
|
311
311
|
useWs(
|
|
312
312
|
sessionId ? `messages:${sessionId}` : '',
|
|
313
313
|
refreshMessages,
|
|
314
|
-
shouldPollMessages ?
|
|
314
|
+
shouldPollMessages ? 10_000 : undefined,
|
|
315
315
|
)
|
|
316
316
|
useWs(
|
|
317
317
|
sessionId ? 'runs' : '',
|
|
318
318
|
refreshQueue,
|
|
319
|
-
sessionId && (isServerActive || queuedCount > 0) ?
|
|
319
|
+
sessionId && (isServerActive || queuedCount > 0) ? 10_000 : undefined,
|
|
320
320
|
)
|
|
321
321
|
|
|
322
322
|
// Listen for stream-end signal from the server — clears streaming state
|
|
@@ -363,7 +363,7 @@ export function ChatArea() {
|
|
|
363
363
|
useWs(
|
|
364
364
|
hasBrowserTool && sessionId ? `browser:${sessionId}` : '',
|
|
365
365
|
checkBrowserStatus,
|
|
366
|
-
hasBrowserTool ?
|
|
366
|
+
hasBrowserTool ? 30_000 : undefined,
|
|
367
367
|
)
|
|
368
368
|
|
|
369
369
|
const handleStopBrowser = useCallback(async () => {
|
|
@@ -64,13 +64,24 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
64
64
|
}))
|
|
65
65
|
}, [sessions, currentUser, showLocalPlatformSessions])
|
|
66
66
|
|
|
67
|
+
// Pre-index connectors by agentId for O(1) lookup instead of O(connectors) per session
|
|
68
|
+
const connectorByAgentId = useMemo(() => {
|
|
69
|
+
const index = new Map<string, typeof connectors[string]>()
|
|
70
|
+
for (const item of Object.values(connectors)) {
|
|
71
|
+
if (item.chatroomId == null && item.agentId && item.isEnabled !== false) {
|
|
72
|
+
index.set(item.agentId, item)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return index
|
|
76
|
+
}, [connectors])
|
|
77
|
+
|
|
67
78
|
const filtered = useMemo(() => {
|
|
68
79
|
return allUserSessions
|
|
69
80
|
.filter((s) => {
|
|
70
81
|
const unreadCount = (getSessionLastAssistantAt(s) || 0) > (lastReadTimestamps[s.id] || 0) ? 1 : 0
|
|
71
82
|
if (search) {
|
|
72
83
|
const agent = s.agentId ? agents[s.agentId] : null
|
|
73
|
-
const connector =
|
|
84
|
+
const connector = s.agentId ? connectorByAgentId.get(s.agentId) : undefined
|
|
74
85
|
const lastMessage = getSessionLastMessage(s)
|
|
75
86
|
const haystack = [
|
|
76
87
|
s.name,
|
|
@@ -102,7 +113,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
102
113
|
if (sortMode === 'messages') return getSessionMessageCount(b) - getSessionMessageCount(a)
|
|
103
114
|
return (b.lastActiveAt || 0) - (a.lastActiveAt || 0)
|
|
104
115
|
})
|
|
105
|
-
}, [agents, allUserSessions,
|
|
116
|
+
}, [agents, allUserSessions, connectorByAgentId, lastReadTimestamps, search, sortMode, typeFilter])
|
|
106
117
|
|
|
107
118
|
const handleSelect = async (id: string) => {
|
|
108
119
|
const agentId = sessions[id]?.agentId
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react'
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
4
|
import { usePathname } from 'next/navigation'
|
|
5
5
|
import { useAppStore } from '@/stores/use-app-store'
|
|
6
6
|
import { Avatar } from '@/components/shared/avatar'
|
|
@@ -8,6 +8,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
|
8
8
|
import { DaemonIndicator } from '@/components/layout/daemon-indicator'
|
|
9
9
|
import { NotificationCenter } from '@/components/shared/notification-center'
|
|
10
10
|
import { NavItem, RailTooltip } from '@/components/layout/nav-item'
|
|
11
|
+
import { useWs } from '@/hooks/use-ws'
|
|
11
12
|
import { FULL_WIDTH_VIEWS } from '@/lib/app/view-constants'
|
|
12
13
|
import { pathToView, useNavigate } from '@/lib/app/navigation'
|
|
13
14
|
import { safeStorageGet, safeStorageSet } from '@/lib/app/safe-storage'
|
|
@@ -29,16 +30,20 @@ export function SidebarRail({
|
|
|
29
30
|
const navigateTo = useNavigate()
|
|
30
31
|
const currentUser = useAppStore((s) => s.currentUser)
|
|
31
32
|
const appSettings = useAppStore((s) => s.appSettings)
|
|
32
|
-
const
|
|
33
|
+
const defaultAgent = useAppStore((s) => {
|
|
34
|
+
const defaultId = s.appSettings.defaultAgentId
|
|
35
|
+
if (defaultId && s.agents[defaultId]) return s.agents[defaultId]
|
|
36
|
+
const first = Object.values(s.agents)[0]
|
|
37
|
+
return first || null
|
|
38
|
+
})
|
|
33
39
|
const currentAgentId = useAppStore((s) => s.currentAgentId)
|
|
34
40
|
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
|
35
41
|
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
|
42
|
+
const skillDraftCount = useAppStore((s) => s.skillDraftCount)
|
|
43
|
+
const loadSkillDraftCount = useAppStore((s) => s.loadSkillDraftCount)
|
|
36
44
|
|
|
37
45
|
const activeView = pathToView(pathname) ?? 'home'
|
|
38
46
|
|
|
39
|
-
const defaultAgent = appSettings.defaultAgentId && agents[appSettings.defaultAgentId]
|
|
40
|
-
? agents[appSettings.defaultAgentId]
|
|
41
|
-
: Object.values(agents)[0] || null
|
|
42
47
|
const defaultAgentId = defaultAgent?.id || null
|
|
43
48
|
const isDefaultChat = activeView === 'agents' && currentAgentId === defaultAgentId
|
|
44
49
|
|
|
@@ -49,6 +54,9 @@ export function SidebarRail({
|
|
|
49
54
|
// Mobile always forces expanded
|
|
50
55
|
const railExpanded = mobile || railExpandedStored
|
|
51
56
|
|
|
57
|
+
useEffect(() => { void loadSkillDraftCount() }, [loadSkillDraftCount])
|
|
58
|
+
useWs('skills', loadSkillDraftCount)
|
|
59
|
+
|
|
52
60
|
const toggleRail = () => {
|
|
53
61
|
if (mobile) return
|
|
54
62
|
const next = !railExpandedStored
|
|
@@ -295,7 +303,7 @@ export function SidebarRail({
|
|
|
295
303
|
<circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
296
304
|
</svg>
|
|
297
305
|
</NavItem>
|
|
298
|
-
<NavItem view="skills" label="Skills" expanded={railExpanded} isActive={isNavActive('skills')} onClick={() => handleNavClick('skills')}>
|
|
306
|
+
<NavItem view="skills" label="Skills" badge={skillDraftCount} expanded={railExpanded} isActive={isNavActive('skills')} onClick={() => handleNavClick('skills')}>
|
|
299
307
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
300
308
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
|
301
309
|
</svg>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
4
4
|
import { api } from '@/lib/app/api-client'
|
|
5
5
|
import { useAppStore } from '@/stores/use-app-store'
|
|
6
6
|
|
|
@@ -21,13 +21,19 @@ interface Link {
|
|
|
21
21
|
type: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Kinetic energy threshold — stop simulation when total energy drops below this. */
|
|
25
|
+
const SETTLE_THRESHOLD = 0.5
|
|
26
|
+
|
|
24
27
|
export function MemoryGraphView() {
|
|
25
|
-
const [
|
|
28
|
+
const [initialData, setInitialData] = useState<{ nodes: Node[]; links: Link[] } | null>(null)
|
|
26
29
|
const [loading, setLoading] = useState(true)
|
|
27
30
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
|
28
31
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
32
|
+
const svgRef = useRef<SVGSVGElement>(null)
|
|
29
33
|
const requestRef = useRef<number>(null)
|
|
30
|
-
|
|
34
|
+
const nodesRef = useRef<Node[]>([])
|
|
35
|
+
const linksRef = useRef<Link[]>([])
|
|
36
|
+
|
|
31
37
|
const selectedMemoryId = useAppStore((s) => s.selectedMemoryId)
|
|
32
38
|
const setSelectedMemoryId = useAppStore((s) => s.setSelectedMemoryId)
|
|
33
39
|
const memoryAgentFilter = useAppStore((s) => s.memoryAgentFilter)
|
|
@@ -38,7 +44,7 @@ export function MemoryGraphView() {
|
|
|
38
44
|
try {
|
|
39
45
|
const url = `/memory/graph${memoryAgentFilter ? `?agentId=${memoryAgentFilter}` : ''}`
|
|
40
46
|
const res = await api<{ nodes: Node[]; links: Link[] }>('GET', url)
|
|
41
|
-
|
|
47
|
+
|
|
42
48
|
// Initialize positions
|
|
43
49
|
const nodes = res.nodes.map(n => ({
|
|
44
50
|
...n,
|
|
@@ -47,8 +53,10 @@ export function MemoryGraphView() {
|
|
|
47
53
|
vx: 0,
|
|
48
54
|
vy: 0
|
|
49
55
|
}))
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
|
|
57
|
+
nodesRef.current = nodes
|
|
58
|
+
linksRef.current = res.links
|
|
59
|
+
setInitialData({ nodes, links: res.links })
|
|
52
60
|
} catch (err) {
|
|
53
61
|
console.error('Failed to load memory graph', err)
|
|
54
62
|
} finally {
|
|
@@ -58,75 +66,112 @@ export function MemoryGraphView() {
|
|
|
58
66
|
load()
|
|
59
67
|
}, [memoryAgentFilter])
|
|
60
68
|
|
|
61
|
-
//
|
|
69
|
+
// Write positions directly to SVG DOM — no React state updates per frame
|
|
70
|
+
const updateDOM = useCallback(() => {
|
|
71
|
+
const svg = svgRef.current
|
|
72
|
+
if (!svg) return
|
|
73
|
+
const nodes = nodesRef.current
|
|
74
|
+
|
|
75
|
+
// Update link positions
|
|
76
|
+
const lineElements = svg.querySelectorAll<SVGLineElement>('[data-link]')
|
|
77
|
+
lineElements.forEach((el) => {
|
|
78
|
+
const srcId = el.getAttribute('data-src')
|
|
79
|
+
const tgtId = el.getAttribute('data-tgt')
|
|
80
|
+
const s = nodes.find(n => n.id === srcId)
|
|
81
|
+
const t = nodes.find(n => n.id === tgtId)
|
|
82
|
+
if (s && t) {
|
|
83
|
+
el.setAttribute('x1', String(s.x))
|
|
84
|
+
el.setAttribute('y1', String(s.y))
|
|
85
|
+
el.setAttribute('x2', String(t.x))
|
|
86
|
+
el.setAttribute('y2', String(t.y))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Update node positions
|
|
91
|
+
const gElements = svg.querySelectorAll<SVGGElement>('[data-node-id]')
|
|
92
|
+
gElements.forEach((el) => {
|
|
93
|
+
const id = el.getAttribute('data-node-id')
|
|
94
|
+
const node = nodes.find(n => n.id === id)
|
|
95
|
+
if (node) {
|
|
96
|
+
el.setAttribute('transform', `translate(${node.x},${node.y})`)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
// Force-directed simulation running in refs, writing to DOM imperatively
|
|
62
102
|
useEffect(() => {
|
|
63
|
-
|
|
103
|
+
const nodes = nodesRef.current
|
|
104
|
+
const links = linksRef.current
|
|
105
|
+
if (nodes.length === 0) return
|
|
64
106
|
|
|
65
107
|
const animate = () => {
|
|
66
|
-
|
|
67
|
-
const nodes = [...prev.nodes]
|
|
68
|
-
const links = prev.links
|
|
69
|
-
|
|
70
|
-
// 1. Repulsion between all nodes
|
|
71
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
72
|
-
for (let j = i + 1; j < nodes.length; j++) {
|
|
73
|
-
const dx = nodes[i].x - nodes[j].x
|
|
74
|
-
const dy = nodes[i].y - nodes[j].y
|
|
75
|
-
const distSq = dx * dx + dy * dy + 0.1
|
|
76
|
-
const force = 400 / distSq
|
|
77
|
-
const fx = dx * force
|
|
78
|
-
const fy = dy * force
|
|
79
|
-
nodes[i].vx += fx
|
|
80
|
-
nodes[i].vy += fy
|
|
81
|
-
nodes[j].vx -= fx
|
|
82
|
-
nodes[j].vy -= fy
|
|
83
|
-
}
|
|
84
|
-
}
|
|
108
|
+
let totalEnergy = 0
|
|
85
109
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
target.vx -= fx
|
|
100
|
-
target.vy -= fy
|
|
101
|
-
}
|
|
110
|
+
// 1. Repulsion between all nodes
|
|
111
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
112
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
113
|
+
const dx = nodes[i].x - nodes[j].x
|
|
114
|
+
const dy = nodes[i].y - nodes[j].y
|
|
115
|
+
const distSq = dx * dx + dy * dy + 0.1
|
|
116
|
+
const force = 400 / distSq
|
|
117
|
+
const fx = dx * force
|
|
118
|
+
const fy = dy * force
|
|
119
|
+
nodes[i].vx += fx
|
|
120
|
+
nodes[i].vy += fy
|
|
121
|
+
nodes[j].vx -= fx
|
|
122
|
+
nodes[j].vy -= fy
|
|
102
123
|
}
|
|
124
|
+
}
|
|
103
125
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
// 2. Attraction along links
|
|
127
|
+
for (const link of links) {
|
|
128
|
+
const source = nodes.find(n => n.id === link.source)
|
|
129
|
+
const target = nodes.find(n => n.id === link.target)
|
|
130
|
+
if (source && target) {
|
|
131
|
+
const dx = target.x - source.x
|
|
132
|
+
const dy = target.y - source.y
|
|
133
|
+
const dist = Math.sqrt(dx * dx + dy * dy) + 0.1
|
|
134
|
+
const force = (dist - 100) * 0.02
|
|
135
|
+
const fx = (dx / dist) * force
|
|
136
|
+
const fy = (dy / dist) * force
|
|
137
|
+
source.vx += fx
|
|
138
|
+
source.vy += fy
|
|
139
|
+
target.vx -= fx
|
|
140
|
+
target.vy -= fy
|
|
110
141
|
}
|
|
142
|
+
}
|
|
111
143
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
144
|
+
// 3. Centering force
|
|
145
|
+
const cx = 400
|
|
146
|
+
const cy = 300
|
|
147
|
+
for (const node of nodes) {
|
|
148
|
+
node.vx += (cx - node.x) * 0.01
|
|
149
|
+
node.vy += (cy - node.y) * 0.01
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. Update positions with damping
|
|
153
|
+
for (const node of nodes) {
|
|
154
|
+
node.x += node.vx
|
|
155
|
+
node.y += node.vy
|
|
156
|
+
node.vx *= 0.8
|
|
157
|
+
node.vy *= 0.8
|
|
158
|
+
totalEnergy += node.vx * node.vx + node.vy * node.vy
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Write positions to DOM imperatively
|
|
162
|
+
updateDOM()
|
|
119
163
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
164
|
+
// Stop when settled
|
|
165
|
+
if (totalEnergy > SETTLE_THRESHOLD) {
|
|
166
|
+
requestRef.current = requestAnimationFrame(animate)
|
|
167
|
+
}
|
|
123
168
|
}
|
|
124
169
|
|
|
125
170
|
requestRef.current = requestAnimationFrame(animate)
|
|
126
171
|
return () => {
|
|
127
172
|
if (requestRef.current) cancelAnimationFrame(requestRef.current)
|
|
128
173
|
}
|
|
129
|
-
}, [
|
|
174
|
+
}, [initialData, updateDOM])
|
|
130
175
|
|
|
131
176
|
if (loading) {
|
|
132
177
|
return (
|
|
@@ -136,17 +181,23 @@ export function MemoryGraphView() {
|
|
|
136
181
|
)
|
|
137
182
|
}
|
|
138
183
|
|
|
184
|
+
const nodes = nodesRef.current
|
|
185
|
+
const links = linksRef.current
|
|
186
|
+
|
|
139
187
|
return (
|
|
140
188
|
<div ref={containerRef} className="flex-1 relative overflow-hidden bg-black/20 rounded-[16px] border border-white/[0.06]">
|
|
141
|
-
<svg width="100%" height="100%" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet">
|
|
189
|
+
<svg ref={svgRef} width="100%" height="100%" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet">
|
|
142
190
|
{/* Links */}
|
|
143
|
-
{
|
|
144
|
-
const s =
|
|
145
|
-
const t =
|
|
191
|
+
{links.map((link, i) => {
|
|
192
|
+
const s = nodes.find(n => n.id === link.source)
|
|
193
|
+
const t = nodes.find(n => n.id === link.target)
|
|
146
194
|
if (!s || !t) return null
|
|
147
195
|
return (
|
|
148
196
|
<line
|
|
149
197
|
key={i}
|
|
198
|
+
data-link=""
|
|
199
|
+
data-src={link.source}
|
|
200
|
+
data-tgt={link.target}
|
|
150
201
|
x1={s.x} y1={s.y}
|
|
151
202
|
x2={t.x} y2={t.y}
|
|
152
203
|
stroke="white"
|
|
@@ -157,9 +208,10 @@ export function MemoryGraphView() {
|
|
|
157
208
|
})}
|
|
158
209
|
|
|
159
210
|
{/* Nodes */}
|
|
160
|
-
{
|
|
161
|
-
<g
|
|
162
|
-
key={node.id}
|
|
211
|
+
{nodes.map(node => (
|
|
212
|
+
<g
|
|
213
|
+
key={node.id}
|
|
214
|
+
data-node-id={node.id}
|
|
163
215
|
transform={`translate(${node.x},${node.y})`}
|
|
164
216
|
onMouseEnter={() => setHoveredNode(node.id)}
|
|
165
217
|
onMouseLeave={() => setHoveredNode(null)}
|
|
@@ -186,7 +238,7 @@ export function MemoryGraphView() {
|
|
|
186
238
|
</g>
|
|
187
239
|
))}
|
|
188
240
|
</svg>
|
|
189
|
-
|
|
241
|
+
|
|
190
242
|
{/* Legend */}
|
|
191
243
|
<div className="absolute bottom-4 left-4 p-3 bg-surface/80 backdrop-blur rounded-[12px] border border-white/[0.06] flex flex-col gap-2">
|
|
192
244
|
<div className="flex items-center gap-2">
|