prompt_objects 0.3.1 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/CLAUDE.md +112 -44
- data/Gemfile.lock +31 -29
- data/README.md +5 -0
- data/frontend/index.html +5 -1
- data/frontend/package-lock.json +123 -0
- data/frontend/package.json +4 -0
- data/frontend/src/App.tsx +70 -71
- data/frontend/src/canvas/CanvasView.tsx +113 -0
- data/frontend/src/canvas/ForceLayout.ts +115 -0
- data/frontend/src/canvas/SceneManager.ts +587 -0
- data/frontend/src/canvas/canvasStore.ts +47 -0
- data/frontend/src/canvas/constants.ts +95 -0
- data/frontend/src/canvas/controls/CameraControls.ts +98 -0
- data/frontend/src/canvas/edges/MessageArc.ts +149 -0
- data/frontend/src/canvas/inspector/InspectorPanel.tsx +31 -0
- data/frontend/src/canvas/inspector/POInspector.tsx +262 -0
- data/frontend/src/canvas/inspector/ToolCallInspector.tsx +67 -0
- data/frontend/src/canvas/nodes/PONode.ts +249 -0
- data/frontend/src/canvas/nodes/ToolCallNode.ts +116 -0
- data/frontend/src/canvas/types.ts +24 -0
- data/frontend/src/components/ContextMenu.tsx +5 -4
- data/frontend/src/components/Inspector.tsx +232 -0
- data/frontend/src/components/MarkdownMessage.tsx +22 -20
- data/frontend/src/components/MethodList.tsx +90 -0
- data/frontend/src/components/ModelSelector.tsx +13 -14
- data/frontend/src/components/NotificationPanel.tsx +29 -33
- data/frontend/src/components/ObjectList.tsx +78 -0
- data/frontend/src/components/PaneSlot.tsx +30 -0
- data/frontend/src/components/SourcePane.tsx +202 -0
- data/frontend/src/components/SystemBar.tsx +74 -0
- data/frontend/src/components/Transcript.tsx +76 -0
- data/frontend/src/components/UsagePanel.tsx +27 -27
- data/frontend/src/components/Workspace.tsx +260 -0
- data/frontend/src/components/index.ts +10 -9
- data/frontend/src/hooks/useResize.ts +55 -0
- data/frontend/src/hooks/useWebSocket.ts +274 -189
- data/frontend/src/index.css +69 -3
- data/frontend/src/store/index.ts +23 -0
- data/frontend/src/types/index.ts +5 -0
- data/frontend/tailwind.config.js +28 -9
- data/lib/prompt_objects/capability.rb +23 -1
- data/lib/prompt_objects/connectors/mcp.rb +5 -22
- data/lib/prompt_objects/environment.rb +8 -0
- data/lib/prompt_objects/llm/openai_adapter.rb +22 -0
- data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +1 -2
- data/lib/prompt_objects/mcp/tools/inspect_po.rb +1 -31
- data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +2 -8
- data/lib/prompt_objects/primitives/list_files.rb +1 -2
- data/lib/prompt_objects/prompt_object.rb +150 -6
- data/lib/prompt_objects/server/api/routes.rb +3 -48
- data/lib/prompt_objects/server/app.rb +9 -0
- data/lib/prompt_objects/server/public/assets/index-D1myxE0l.js +4345 -0
- data/lib/prompt_objects/server/public/assets/index-DdCcwC-Z.css +1 -0
- data/lib/prompt_objects/server/public/index.html +7 -3
- data/lib/prompt_objects/server/websocket_handler.rb +23 -100
- data/lib/prompt_objects/server.rb +6 -62
- data/prompt_objects.gemspec +1 -1
- data/templates/arc-agi-1/primitives/find_objects.rb +1 -1
- data/templates/arc-agi-1/primitives/grid_diff.rb +2 -2
- data/templates/arc-agi-1/primitives/grid_info.rb +1 -1
- data/templates/arc-agi-1/primitives/grid_transform.rb +1 -1
- data/templates/arc-agi-1/primitives/render_grid.rb +1 -0
- data/templates/arc-agi-1/primitives/test_solution.rb +3 -0
- metadata +26 -14
- data/frontend/src/components/CapabilitiesPanel.tsx +0 -141
- data/frontend/src/components/ChatPanel.tsx +0 -288
- data/frontend/src/components/Dashboard.tsx +0 -83
- data/frontend/src/components/Header.tsx +0 -141
- data/frontend/src/components/MessageBus.tsx +0 -56
- data/frontend/src/components/POCard.tsx +0 -56
- data/frontend/src/components/PODetail.tsx +0 -124
- data/frontend/src/components/PromptPanel.tsx +0 -156
- data/frontend/src/components/SessionsPanel.tsx +0 -174
- data/frontend/src/components/ThreadsSidebar.tsx +0 -163
- data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +0 -1
- data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +0 -77
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js'
|
|
3
|
+
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
|
|
4
|
+
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
|
|
5
|
+
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
|
|
6
|
+
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
|
|
7
|
+
|
|
8
|
+
import { ForceLayout } from './ForceLayout'
|
|
9
|
+
import { CameraControls } from './controls/CameraControls'
|
|
10
|
+
import { PONode } from './nodes/PONode'
|
|
11
|
+
import { ToolCallNode } from './nodes/ToolCallNode'
|
|
12
|
+
import { MessageArc } from './edges/MessageArc'
|
|
13
|
+
import { useCanvasStore } from './canvasStore'
|
|
14
|
+
import { COLORS, BLOOM, NODE } from './constants'
|
|
15
|
+
import type { PromptObject, BusMessage, Notification, ToolCall } from '../types'
|
|
16
|
+
|
|
17
|
+
export class SceneManager {
|
|
18
|
+
private container: HTMLDivElement
|
|
19
|
+
private scene: THREE.Scene
|
|
20
|
+
private camera: THREE.OrthographicCamera
|
|
21
|
+
private renderer: THREE.WebGLRenderer
|
|
22
|
+
private cssRenderer: CSS2DRenderer
|
|
23
|
+
private composer: EffectComposer
|
|
24
|
+
private cameraControls: CameraControls
|
|
25
|
+
private forceLayout: ForceLayout
|
|
26
|
+
private raycaster: THREE.Raycaster
|
|
27
|
+
private pointer: THREE.Vector2
|
|
28
|
+
|
|
29
|
+
private poNodes = new Map<string, PONode>()
|
|
30
|
+
private toolCallNodes = new Map<string, ToolCallNode>()
|
|
31
|
+
private messageArcs = new Map<string, MessageArc>()
|
|
32
|
+
|
|
33
|
+
private animationFrameId: number | null = null
|
|
34
|
+
private clock = new THREE.Clock()
|
|
35
|
+
private running = false
|
|
36
|
+
|
|
37
|
+
// Track processed bus messages to avoid duplicates
|
|
38
|
+
private processedArcIds = new Set<string>()
|
|
39
|
+
|
|
40
|
+
// Track PO status transitions and tool calls
|
|
41
|
+
private prevPOStatuses = new Map<string, string>()
|
|
42
|
+
private seenToolCallIds = new Set<string>()
|
|
43
|
+
// Map from PO name → set of tool call node IDs currently active for that PO
|
|
44
|
+
private poToolCallNodes = new Map<string, Set<string>>()
|
|
45
|
+
// Track active PO-to-PO delegations: tool_call_id → { callerPO, targetPO }
|
|
46
|
+
private activeDelegations = new Map<string, { callerPO: string; targetPO: string }>()
|
|
47
|
+
|
|
48
|
+
constructor(container: HTMLDivElement) {
|
|
49
|
+
this.container = container
|
|
50
|
+
const { width, height } = container.getBoundingClientRect()
|
|
51
|
+
|
|
52
|
+
// Scene
|
|
53
|
+
this.scene = new THREE.Scene()
|
|
54
|
+
this.scene.background = new THREE.Color(COLORS.background)
|
|
55
|
+
|
|
56
|
+
// Orthographic camera (true 2D)
|
|
57
|
+
const aspect = width / height
|
|
58
|
+
const frustumSize = 500
|
|
59
|
+
this.camera = new THREE.OrthographicCamera(
|
|
60
|
+
(-frustumSize * aspect) / 2,
|
|
61
|
+
(frustumSize * aspect) / 2,
|
|
62
|
+
frustumSize / 2,
|
|
63
|
+
-frustumSize / 2,
|
|
64
|
+
0.1,
|
|
65
|
+
1000
|
|
66
|
+
)
|
|
67
|
+
this.camera.position.z = 100
|
|
68
|
+
|
|
69
|
+
// WebGL renderer
|
|
70
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true })
|
|
71
|
+
this.renderer.setSize(width, height)
|
|
72
|
+
this.renderer.setPixelRatio(window.devicePixelRatio)
|
|
73
|
+
container.appendChild(this.renderer.domElement)
|
|
74
|
+
|
|
75
|
+
// CSS2D renderer (overlaid for labels)
|
|
76
|
+
this.cssRenderer = new CSS2DRenderer()
|
|
77
|
+
this.cssRenderer.setSize(width, height)
|
|
78
|
+
this.cssRenderer.domElement.style.position = 'absolute'
|
|
79
|
+
this.cssRenderer.domElement.style.top = '0'
|
|
80
|
+
this.cssRenderer.domElement.style.left = '0'
|
|
81
|
+
this.cssRenderer.domElement.style.pointerEvents = 'none'
|
|
82
|
+
container.appendChild(this.cssRenderer.domElement)
|
|
83
|
+
|
|
84
|
+
// Post-processing (bloom)
|
|
85
|
+
this.composer = new EffectComposer(this.renderer)
|
|
86
|
+
this.composer.addPass(new RenderPass(this.scene, this.camera))
|
|
87
|
+
const bloomPass = new UnrealBloomPass(
|
|
88
|
+
new THREE.Vector2(width, height),
|
|
89
|
+
BLOOM.strength,
|
|
90
|
+
BLOOM.radius,
|
|
91
|
+
BLOOM.threshold
|
|
92
|
+
)
|
|
93
|
+
this.composer.addPass(bloomPass)
|
|
94
|
+
this.composer.addPass(new OutputPass())
|
|
95
|
+
|
|
96
|
+
// Camera controls
|
|
97
|
+
this.cameraControls = new CameraControls(this.camera, this.renderer.domElement)
|
|
98
|
+
|
|
99
|
+
// Force layout
|
|
100
|
+
this.forceLayout = new ForceLayout()
|
|
101
|
+
|
|
102
|
+
// Raycaster for picking
|
|
103
|
+
this.raycaster = new THREE.Raycaster()
|
|
104
|
+
this.pointer = new THREE.Vector2()
|
|
105
|
+
|
|
106
|
+
// Event listeners
|
|
107
|
+
this.renderer.domElement.addEventListener('click', this.onClick)
|
|
108
|
+
this.renderer.domElement.addEventListener('pointermove', this.onPointerMove)
|
|
109
|
+
window.addEventListener('resize', this.onResize)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Public sync API ---
|
|
113
|
+
|
|
114
|
+
syncPromptObjects(pos: Record<string, PromptObject>): void {
|
|
115
|
+
const currentIds = new Set(this.poNodes.keys())
|
|
116
|
+
const newIds = new Set(Object.keys(pos))
|
|
117
|
+
|
|
118
|
+
// Remove nodes that no longer exist
|
|
119
|
+
for (const id of currentIds) {
|
|
120
|
+
if (!newIds.has(id)) {
|
|
121
|
+
const node = this.poNodes.get(id)!
|
|
122
|
+
this.scene.remove(node.group)
|
|
123
|
+
node.dispose()
|
|
124
|
+
this.poNodes.delete(id)
|
|
125
|
+
this.forceLayout.removeNode(id)
|
|
126
|
+
this.fadeOutToolCallsForPO(id)
|
|
127
|
+
this.prevPOStatuses.delete(id)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Add or update nodes
|
|
132
|
+
for (const [name, po] of Object.entries(pos)) {
|
|
133
|
+
let node = this.poNodes.get(name)
|
|
134
|
+
if (!node) {
|
|
135
|
+
node = new PONode(name, name)
|
|
136
|
+
this.poNodes.set(name, node)
|
|
137
|
+
this.scene.add(node.group)
|
|
138
|
+
this.forceLayout.addNode(name, 'po')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle delegation visuals (server-driven via po_delegation_started/completed)
|
|
142
|
+
if (po.delegated_by) {
|
|
143
|
+
// This PO is being called by another PO — show delegation state
|
|
144
|
+
if (!this.activeDelegations.has(name)) {
|
|
145
|
+
// New delegation — activate visual and create arc
|
|
146
|
+
node.setDelegatedBy(po.delegated_by)
|
|
147
|
+
this.activeDelegations.set(name, { callerPO: po.delegated_by, targetPO: name })
|
|
148
|
+
|
|
149
|
+
// Create a message arc from caller to target
|
|
150
|
+
const callerNode = this.poNodes.get(po.delegated_by)
|
|
151
|
+
if (callerNode) {
|
|
152
|
+
const arcId = `delegation-${name}`
|
|
153
|
+
if (!this.messageArcs.has(arcId)) {
|
|
154
|
+
const arc = new MessageArc(
|
|
155
|
+
arcId,
|
|
156
|
+
po.delegated_by,
|
|
157
|
+
name,
|
|
158
|
+
callerNode.getPosition(),
|
|
159
|
+
node.getPosition()
|
|
160
|
+
)
|
|
161
|
+
this.messageArcs.set(arcId, arc)
|
|
162
|
+
this.scene.add(arc.group)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Skip normal setStatus when delegated — the delegation visual takes priority
|
|
167
|
+
} else {
|
|
168
|
+
// Not delegated — check if we need to clear a previous delegation
|
|
169
|
+
if (this.activeDelegations.has(name)) {
|
|
170
|
+
node.clearDelegated()
|
|
171
|
+
this.activeDelegations.delete(name)
|
|
172
|
+
}
|
|
173
|
+
node.setStatus(po.status)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Detect status transitions for tool call visualization
|
|
177
|
+
const prevStatus = this.prevPOStatuses.get(name)
|
|
178
|
+
this.prevPOStatuses.set(name, po.status)
|
|
179
|
+
|
|
180
|
+
if (po.status === 'calling_tool') {
|
|
181
|
+
// PO is calling tools — extract any new tool_calls from its messages
|
|
182
|
+
this.extractAndCreateToolCalls(name, po)
|
|
183
|
+
} else if (prevStatus === 'calling_tool') {
|
|
184
|
+
// PO just finished calling tools — fade out its tool call nodes
|
|
185
|
+
this.fadeOutToolCallsForPO(name)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Even when 'thinking', scan for tool calls we haven't seen yet.
|
|
189
|
+
// The server often sends status back to 'thinking' between individual
|
|
190
|
+
// tool calls in a multi-tool sequence, so we'd miss them if we only
|
|
191
|
+
// check on the 'calling_tool' transition.
|
|
192
|
+
if (po.status === 'thinking') {
|
|
193
|
+
this.extractAndCreateToolCalls(name, po)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
syncBusMessages(messages: BusMessage[]): void {
|
|
199
|
+
for (const msg of messages) {
|
|
200
|
+
const arcId = `${msg.from}-${msg.to}-${msg.timestamp}`
|
|
201
|
+
if (this.processedArcIds.has(arcId)) continue
|
|
202
|
+
this.processedArcIds.add(arcId)
|
|
203
|
+
|
|
204
|
+
const fromNode = this.poNodes.get(msg.from)
|
|
205
|
+
const toNode = this.poNodes.get(msg.to)
|
|
206
|
+
if (!fromNode || !toNode) continue
|
|
207
|
+
|
|
208
|
+
// Create arc for all PO-to-PO bus messages
|
|
209
|
+
const arc = new MessageArc(
|
|
210
|
+
arcId,
|
|
211
|
+
msg.from,
|
|
212
|
+
msg.to,
|
|
213
|
+
fromNode.getPosition(),
|
|
214
|
+
toNode.getPosition()
|
|
215
|
+
)
|
|
216
|
+
this.messageArcs.set(arcId, arc)
|
|
217
|
+
this.scene.add(arc.group)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Keep processed sets from growing unbounded
|
|
221
|
+
if (this.processedArcIds.size > 500) {
|
|
222
|
+
const ids = Array.from(this.processedArcIds)
|
|
223
|
+
for (let i = 0; i < 200; i++) {
|
|
224
|
+
this.processedArcIds.delete(ids[i])
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (this.seenToolCallIds.size > 200) {
|
|
228
|
+
const ids = Array.from(this.seenToolCallIds)
|
|
229
|
+
for (let i = 0; i < 100; i++) {
|
|
230
|
+
this.seenToolCallIds.delete(ids[i])
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
syncNotifications(notifications: Notification[]): void {
|
|
236
|
+
// Count notifications per PO
|
|
237
|
+
const counts = new Map<string, number>()
|
|
238
|
+
for (const n of notifications) {
|
|
239
|
+
counts.set(n.po_name, (counts.get(n.po_name) || 0) + 1)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Update badges on all PO nodes
|
|
243
|
+
for (const [name, node] of this.poNodes) {
|
|
244
|
+
node.setNotificationCount(counts.get(name) || 0)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fitAll(): void {
|
|
249
|
+
if (this.poNodes.size === 0) return
|
|
250
|
+
|
|
251
|
+
const bounds = new THREE.Box3()
|
|
252
|
+
for (const node of this.poNodes.values()) {
|
|
253
|
+
bounds.expandByPoint(node.getPosition())
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Expand bounds by node radius
|
|
257
|
+
const padding = new THREE.Vector3(100, 100, 0)
|
|
258
|
+
bounds.min.sub(padding)
|
|
259
|
+
bounds.max.add(padding)
|
|
260
|
+
|
|
261
|
+
this.cameraControls.fitAll(bounds)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- Tool call extraction ---
|
|
265
|
+
|
|
266
|
+
private extractAndCreateToolCalls(poName: string, po: PromptObject): void {
|
|
267
|
+
const messages = po.current_session?.messages
|
|
268
|
+
if (!messages || messages.length === 0) return
|
|
269
|
+
|
|
270
|
+
// Scan recent messages for tool_calls we haven't visualized yet,
|
|
271
|
+
// and also for tool results to enrich existing tool call entries.
|
|
272
|
+
const newToolCalls: ToolCall[] = []
|
|
273
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
274
|
+
const msg = messages[i]
|
|
275
|
+
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
276
|
+
for (const tc of msg.tool_calls) {
|
|
277
|
+
if (!this.seenToolCallIds.has(tc.id)) {
|
|
278
|
+
newToolCalls.push(tc)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Enrich with results from tool messages
|
|
283
|
+
if (msg.role === 'tool' && msg.results) {
|
|
284
|
+
for (const result of msg.results) {
|
|
285
|
+
const tcNodeId = `tc-${result.tool_call_id}`
|
|
286
|
+
if (this.toolCallNodes.has(tcNodeId)) {
|
|
287
|
+
useCanvasStore.getState().updateToolCall(tcNodeId, {
|
|
288
|
+
result: result.content,
|
|
289
|
+
status: 'completed',
|
|
290
|
+
completedAt: Date.now(),
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Don't scan the entire history — last 10 messages is enough
|
|
296
|
+
// (a multi-tool call can generate several messages)
|
|
297
|
+
if (messages.length - 1 - i >= 10) break
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (newToolCalls.length === 0) return
|
|
301
|
+
|
|
302
|
+
const callerNode = this.poNodes.get(poName)
|
|
303
|
+
if (!callerNode) return
|
|
304
|
+
const callerPos = callerNode.getPosition()
|
|
305
|
+
|
|
306
|
+
// Get how many tool call nodes this PO already has (for offset positioning)
|
|
307
|
+
let poTcSet = this.poToolCallNodes.get(poName)
|
|
308
|
+
if (!poTcSet) {
|
|
309
|
+
poTcSet = new Set()
|
|
310
|
+
this.poToolCallNodes.set(poName, poTcSet)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const tc of newToolCalls) {
|
|
314
|
+
this.seenToolCallIds.add(tc.id)
|
|
315
|
+
|
|
316
|
+
// Skip PO-to-PO calls — delegation visuals are handled by server events
|
|
317
|
+
// (po_delegation_started/completed → delegated_by in store → syncPromptObjects)
|
|
318
|
+
if (this.poNodes.has(tc.name)) continue
|
|
319
|
+
|
|
320
|
+
// Primitive tool call: create a diamond ToolCallNode
|
|
321
|
+
const existingCount = poTcSet.size
|
|
322
|
+
const angle = (existingCount * (2 * Math.PI / 6)) - Math.PI / 2
|
|
323
|
+
const offsetDist = NODE.poRadius + 40
|
|
324
|
+
const offsetX = Math.cos(angle) * offsetDist
|
|
325
|
+
const offsetY = Math.sin(angle) * offsetDist
|
|
326
|
+
|
|
327
|
+
const tcNodeId = `tc-${tc.id}`
|
|
328
|
+
const tcNode = new ToolCallNode(tcNodeId, tc.name, poName)
|
|
329
|
+
tcNode.setPosition(callerPos.x + offsetX, callerPos.y + offsetY)
|
|
330
|
+
|
|
331
|
+
this.toolCallNodes.set(tcNodeId, tcNode)
|
|
332
|
+
this.scene.add(tcNode.group)
|
|
333
|
+
poTcSet.add(tcNodeId)
|
|
334
|
+
|
|
335
|
+
// Register in canvas store for inspector
|
|
336
|
+
useCanvasStore.getState().addToolCall({
|
|
337
|
+
id: tcNodeId,
|
|
338
|
+
toolName: tc.name,
|
|
339
|
+
callerPO: poName,
|
|
340
|
+
params: tc.arguments,
|
|
341
|
+
status: 'active',
|
|
342
|
+
startedAt: Date.now(),
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private fadeOutToolCallsForPO(poName: string): void {
|
|
348
|
+
const tcSet = this.poToolCallNodes.get(poName)
|
|
349
|
+
if (!tcSet) return
|
|
350
|
+
|
|
351
|
+
for (const tcNodeId of tcSet) {
|
|
352
|
+
const tcNode = this.toolCallNodes.get(tcNodeId)
|
|
353
|
+
if (tcNode) {
|
|
354
|
+
tcNode.triggerFadeOut()
|
|
355
|
+
}
|
|
356
|
+
// Mark as completed in canvas store
|
|
357
|
+
useCanvasStore.getState().updateToolCall(tcNodeId, {
|
|
358
|
+
status: 'completed',
|
|
359
|
+
completedAt: Date.now(),
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
// Clear the set — expired nodes will be removed by the animation loop
|
|
363
|
+
tcSet.clear()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- Lifecycle ---
|
|
367
|
+
|
|
368
|
+
start(): void {
|
|
369
|
+
if (this.running) return
|
|
370
|
+
this.running = true
|
|
371
|
+
this.clock.start()
|
|
372
|
+
this.animate()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
stop(): void {
|
|
376
|
+
this.running = false
|
|
377
|
+
if (this.animationFrameId !== null) {
|
|
378
|
+
cancelAnimationFrame(this.animationFrameId)
|
|
379
|
+
this.animationFrameId = null
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
dispose(): void {
|
|
384
|
+
this.stop()
|
|
385
|
+
|
|
386
|
+
// Dispose all nodes
|
|
387
|
+
for (const node of this.poNodes.values()) {
|
|
388
|
+
node.dispose()
|
|
389
|
+
}
|
|
390
|
+
this.poNodes.clear()
|
|
391
|
+
|
|
392
|
+
for (const node of this.toolCallNodes.values()) {
|
|
393
|
+
node.dispose()
|
|
394
|
+
}
|
|
395
|
+
this.toolCallNodes.clear()
|
|
396
|
+
|
|
397
|
+
for (const arc of this.messageArcs.values()) {
|
|
398
|
+
arc.dispose()
|
|
399
|
+
}
|
|
400
|
+
this.messageArcs.clear()
|
|
401
|
+
|
|
402
|
+
// Dispose Three.js resources
|
|
403
|
+
this.forceLayout.dispose()
|
|
404
|
+
this.cameraControls.dispose()
|
|
405
|
+
this.composer.dispose()
|
|
406
|
+
this.renderer.dispose()
|
|
407
|
+
|
|
408
|
+
// Remove event listeners
|
|
409
|
+
this.renderer.domElement.removeEventListener('click', this.onClick)
|
|
410
|
+
this.renderer.domElement.removeEventListener('pointermove', this.onPointerMove)
|
|
411
|
+
window.removeEventListener('resize', this.onResize)
|
|
412
|
+
|
|
413
|
+
// Remove DOM elements
|
|
414
|
+
this.container.removeChild(this.renderer.domElement)
|
|
415
|
+
this.container.removeChild(this.cssRenderer.domElement)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- Animation loop ---
|
|
419
|
+
|
|
420
|
+
private animate = (): void => {
|
|
421
|
+
if (!this.running) return
|
|
422
|
+
this.animationFrameId = requestAnimationFrame(this.animate)
|
|
423
|
+
|
|
424
|
+
const delta = this.clock.getDelta()
|
|
425
|
+
const elapsed = this.clock.elapsedTime
|
|
426
|
+
|
|
427
|
+
// 1. Tick force layout → get positions
|
|
428
|
+
this.forceLayout.tick()
|
|
429
|
+
const positions = this.forceLayout.getPositions()
|
|
430
|
+
|
|
431
|
+
// 2. Update PO node positions (lerp) and animations
|
|
432
|
+
for (const [id, node] of this.poNodes) {
|
|
433
|
+
const pos = positions.get(id)
|
|
434
|
+
if (pos) {
|
|
435
|
+
node.setPosition(pos.x, pos.y)
|
|
436
|
+
}
|
|
437
|
+
node.update(delta, elapsed)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 3. Update tool call node positions to follow their caller PO
|
|
441
|
+
for (const [poName, tcSet] of this.poToolCallNodes) {
|
|
442
|
+
const callerNode = this.poNodes.get(poName)
|
|
443
|
+
if (!callerNode) continue
|
|
444
|
+
const callerPos = callerNode.getPosition()
|
|
445
|
+
|
|
446
|
+
let i = 0
|
|
447
|
+
for (const tcNodeId of tcSet) {
|
|
448
|
+
const tcNode = this.toolCallNodes.get(tcNodeId)
|
|
449
|
+
if (!tcNode) continue
|
|
450
|
+
// Keep tool call nodes orbiting around caller
|
|
451
|
+
const angle = (i * (2 * Math.PI / Math.max(tcSet.size, 1))) - Math.PI / 2
|
|
452
|
+
const offsetDist = NODE.poRadius + 40
|
|
453
|
+
tcNode.setPosition(
|
|
454
|
+
callerPos.x + Math.cos(angle) * offsetDist,
|
|
455
|
+
callerPos.y + Math.sin(angle) * offsetDist
|
|
456
|
+
)
|
|
457
|
+
i++
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 4. Update arcs (recalculate endpoints, advance particles)
|
|
462
|
+
for (const [id, arc] of this.messageArcs) {
|
|
463
|
+
const fromNode = this.poNodes.get(arc.from)
|
|
464
|
+
const toNode = this.poNodes.get(arc.to)
|
|
465
|
+
if (fromNode && toNode) {
|
|
466
|
+
arc.updateEndpoints(fromNode.getPosition(), toNode.getPosition())
|
|
467
|
+
}
|
|
468
|
+
arc.update(delta)
|
|
469
|
+
|
|
470
|
+
if (arc.isExpired()) {
|
|
471
|
+
this.scene.remove(arc.group)
|
|
472
|
+
arc.dispose()
|
|
473
|
+
this.messageArcs.delete(id)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 5. Update tool call nodes (lifecycle)
|
|
478
|
+
for (const [id, node] of this.toolCallNodes) {
|
|
479
|
+
node.update(delta)
|
|
480
|
+
if (node.isExpired()) {
|
|
481
|
+
this.scene.remove(node.group)
|
|
482
|
+
node.dispose()
|
|
483
|
+
this.toolCallNodes.delete(id)
|
|
484
|
+
// Clean up from canvas store
|
|
485
|
+
useCanvasStore.getState().removeToolCall(id)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 6. Render
|
|
490
|
+
this.composer.render()
|
|
491
|
+
this.cssRenderer.render(this.scene, this.camera)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// --- Event handlers ---
|
|
495
|
+
|
|
496
|
+
private onClick = (event: MouseEvent): void => {
|
|
497
|
+
this.updatePointer(event)
|
|
498
|
+
this.raycaster.setFromCamera(this.pointer, this.camera)
|
|
499
|
+
|
|
500
|
+
const meshes = this.getMeshes()
|
|
501
|
+
const intersects = this.raycaster.intersectObjects(meshes)
|
|
502
|
+
|
|
503
|
+
if (intersects.length > 0) {
|
|
504
|
+
const obj = intersects[0].object
|
|
505
|
+
const { type, id } = obj.userData
|
|
506
|
+
if (type && id) {
|
|
507
|
+
useCanvasStore.getState().selectNode({ type, id })
|
|
508
|
+
|
|
509
|
+
// Update visual selection on PO nodes
|
|
510
|
+
for (const node of this.poNodes.values()) {
|
|
511
|
+
node.setSelected(node.id === id)
|
|
512
|
+
}
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Click on empty space — deselect
|
|
518
|
+
useCanvasStore.getState().selectNode(null)
|
|
519
|
+
for (const node of this.poNodes.values()) {
|
|
520
|
+
node.setSelected(false)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private onPointerMove = (event: MouseEvent): void => {
|
|
525
|
+
this.updatePointer(event)
|
|
526
|
+
this.raycaster.setFromCamera(this.pointer, this.camera)
|
|
527
|
+
|
|
528
|
+
const meshes = this.getMeshes()
|
|
529
|
+
const intersects = this.raycaster.intersectObjects(meshes)
|
|
530
|
+
|
|
531
|
+
// Reset all hover states
|
|
532
|
+
for (const node of this.poNodes.values()) {
|
|
533
|
+
node.setHovered(false)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (intersects.length > 0) {
|
|
537
|
+
const obj = intersects[0].object
|
|
538
|
+
const { type, id } = obj.userData
|
|
539
|
+
if (type === 'po' && id) {
|
|
540
|
+
const node = this.poNodes.get(id)
|
|
541
|
+
if (node) {
|
|
542
|
+
node.setHovered(true)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
this.renderer.domElement.style.cursor = 'pointer'
|
|
546
|
+
useCanvasStore.getState().setHoveredNode(id || null)
|
|
547
|
+
} else {
|
|
548
|
+
this.renderer.domElement.style.cursor = 'default'
|
|
549
|
+
useCanvasStore.getState().setHoveredNode(null)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private onResize = (): void => {
|
|
554
|
+
const { width, height } = this.container.getBoundingClientRect()
|
|
555
|
+
const aspect = width / height
|
|
556
|
+
const frustumSize = 500
|
|
557
|
+
|
|
558
|
+
this.camera.left = (-frustumSize * aspect) / 2
|
|
559
|
+
this.camera.right = (frustumSize * aspect) / 2
|
|
560
|
+
this.camera.top = frustumSize / 2
|
|
561
|
+
this.camera.bottom = -frustumSize / 2
|
|
562
|
+
this.camera.updateProjectionMatrix()
|
|
563
|
+
|
|
564
|
+
this.renderer.setSize(width, height)
|
|
565
|
+
this.cssRenderer.setSize(width, height)
|
|
566
|
+
this.composer.setSize(width, height)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// --- Helpers ---
|
|
570
|
+
|
|
571
|
+
private updatePointer(event: MouseEvent): void {
|
|
572
|
+
const rect = this.renderer.domElement.getBoundingClientRect()
|
|
573
|
+
this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
574
|
+
this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private getMeshes(): THREE.Mesh[] {
|
|
578
|
+
const meshes: THREE.Mesh[] = []
|
|
579
|
+
for (const node of this.poNodes.values()) {
|
|
580
|
+
meshes.push(node.mesh)
|
|
581
|
+
}
|
|
582
|
+
for (const node of this.toolCallNodes.values()) {
|
|
583
|
+
meshes.push(node.mesh)
|
|
584
|
+
}
|
|
585
|
+
return meshes
|
|
586
|
+
}
|
|
587
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import type { CanvasNodeSelection, ActiveToolCall } from './types'
|
|
3
|
+
|
|
4
|
+
interface CanvasStore {
|
|
5
|
+
selectedNode: CanvasNodeSelection | null
|
|
6
|
+
hoveredNode: string | null
|
|
7
|
+
showLabels: boolean
|
|
8
|
+
activeToolCalls: Map<string, ActiveToolCall>
|
|
9
|
+
|
|
10
|
+
selectNode: (node: CanvasNodeSelection | null) => void
|
|
11
|
+
setHoveredNode: (id: string | null) => void
|
|
12
|
+
toggleLabels: () => void
|
|
13
|
+
addToolCall: (tc: ActiveToolCall) => void
|
|
14
|
+
updateToolCall: (id: string, update: Partial<ActiveToolCall>) => void
|
|
15
|
+
removeToolCall: (id: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const useCanvasStore = create<CanvasStore>((set) => ({
|
|
19
|
+
selectedNode: null,
|
|
20
|
+
hoveredNode: null,
|
|
21
|
+
showLabels: true,
|
|
22
|
+
activeToolCalls: new Map(),
|
|
23
|
+
|
|
24
|
+
selectNode: (node) => set({ selectedNode: node }),
|
|
25
|
+
setHoveredNode: (id) => set({ hoveredNode: id }),
|
|
26
|
+
toggleLabels: () => set((s) => ({ showLabels: !s.showLabels })),
|
|
27
|
+
addToolCall: (tc) =>
|
|
28
|
+
set((s) => {
|
|
29
|
+
const next = new Map(s.activeToolCalls)
|
|
30
|
+
next.set(tc.id, tc)
|
|
31
|
+
return { activeToolCalls: next }
|
|
32
|
+
}),
|
|
33
|
+
updateToolCall: (id, update) =>
|
|
34
|
+
set((s) => {
|
|
35
|
+
const existing = s.activeToolCalls.get(id)
|
|
36
|
+
if (!existing) return s
|
|
37
|
+
const next = new Map(s.activeToolCalls)
|
|
38
|
+
next.set(id, { ...existing, ...update })
|
|
39
|
+
return { activeToolCalls: next }
|
|
40
|
+
}),
|
|
41
|
+
removeToolCall: (id) =>
|
|
42
|
+
set((s) => {
|
|
43
|
+
const next = new Map(s.activeToolCalls)
|
|
44
|
+
next.delete(id)
|
|
45
|
+
return { activeToolCalls: next }
|
|
46
|
+
}),
|
|
47
|
+
}))
|