opencube 0.1.1 → 0.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 +39 -3
- package/package.json +1 -1
- package/src/main.js +535 -24
- package/src/plugin-server.cjs +36 -0
package/README.md
CHANGED
|
@@ -9,7 +9,11 @@ OpenCube is a tiny desktop pet for [opencode](https://opencode.ai/).
|
|
|
9
9
|
It watches opencode session activity and renders a small Three.js cube on your desktop:
|
|
10
10
|
|
|
11
11
|
- busy sessions light up cube faces
|
|
12
|
-
-
|
|
12
|
+
- active tool calls emit bright face-colored sparks from the corresponding cube face
|
|
13
|
+
- right-click drag applies extra rotational friction with small braking particles
|
|
14
|
+
- idle sessions keep a subtle torque-breathing motion and release face glow after the cube slows down
|
|
15
|
+
- a macOS-style tray/menu bar control can show, hide, quit, or open the Inbox
|
|
16
|
+
- the Inbox shows raw opencode hook events plus local mouse/keyboard diagnostics
|
|
13
17
|
- `/pet_say_hello` flashes one free face
|
|
14
18
|
- `/pet_fancy_say_hello` plays a randomized light show on free faces
|
|
15
19
|
|
|
@@ -48,6 +52,21 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
|
|
|
48
52
|
|
|
49
53
|
These commands are handled by the plugin and do not get sent to the model.
|
|
50
54
|
|
|
55
|
+
## What you can see
|
|
56
|
+
|
|
57
|
+
OpenCube turns opencode lifecycle events into small desktop signals:
|
|
58
|
+
|
|
59
|
+
| opencode activity | OpenCube signal |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| Session becomes busy | One cube face lights up with a stable session color. |
|
|
62
|
+
| Session becomes idle | The face glow is released once the cube slows down. |
|
|
63
|
+
| A tool call starts | The session's face emits fast, bright sparks in that face's current outward direction. |
|
|
64
|
+
| A tool call finishes | Spark emission stops; existing sparks fade out naturally. |
|
|
65
|
+
| Right mouse hold on the cube | Friction increases and braking particles appear. |
|
|
66
|
+
| Tray menu → Show Inbox | Opens a two-column event/debug panel. |
|
|
67
|
+
|
|
68
|
+
Multiple busy sessions can light multiple faces. If more than six sessions are busy, OpenCube shows the latest six.
|
|
69
|
+
|
|
51
70
|
## How it works
|
|
52
71
|
|
|
53
72
|
OpenCube has two parts in one npm package:
|
|
@@ -55,13 +74,28 @@ OpenCube has two parts in one npm package:
|
|
|
55
74
|
1. `src/plugin-server.cjs` — the opencode plugin entrypoint.
|
|
56
75
|
2. `src/main.js` — the Electron desktop pet.
|
|
57
76
|
|
|
58
|
-
The plugin registers slash commands, listens for opencode `session.status` events, and sends events to the desktop pet over a local HTTP API:
|
|
77
|
+
The plugin registers slash commands, listens for opencode `session.status` events and `tool.execute.*` hooks, and sends events to the desktop pet over a local HTTP API:
|
|
59
78
|
|
|
60
79
|
```text
|
|
61
80
|
opencode plugin -> http://127.0.0.1:47832 -> Electron OpenCube
|
|
62
81
|
```
|
|
63
82
|
|
|
64
|
-
The Electron process owns the window, Three.js renderer, cube rotation, face glow state, and inbox/debug endpoints.
|
|
83
|
+
The Electron process owns the window, tray menu, Three.js renderer, cube rotation, face glow state, particle effects, and inbox/debug endpoints.
|
|
84
|
+
|
|
85
|
+
## Local API
|
|
86
|
+
|
|
87
|
+
OpenCube exposes a local-only HTTP API while it is running:
|
|
88
|
+
|
|
89
|
+
| Endpoint | Purpose |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `GET /health` | Check whether OpenCube is running. |
|
|
92
|
+
| `GET /debug-render` | Inspect renderer state, busy faces, active tools, and particle counters. |
|
|
93
|
+
| `POST /event` | Receive opencode lifecycle/tool/hello events. |
|
|
94
|
+
| `POST /interaction` | Receive local renderer mouse/keyboard diagnostics. |
|
|
95
|
+
| `POST /show` | Show the cube window. |
|
|
96
|
+
| `POST /quit` | Quit OpenCube. |
|
|
97
|
+
|
|
98
|
+
The API binds to `127.0.0.1` only.
|
|
65
99
|
|
|
66
100
|
## Requirements
|
|
67
101
|
|
|
@@ -74,9 +108,11 @@ Users do not need to run `npm install` manually when installing via `opencode pl
|
|
|
74
108
|
## Notes
|
|
75
109
|
|
|
76
110
|
- The first install may take a while because Electron is downloaded as a runtime dependency.
|
|
111
|
+
- If Electron's platform binary is missing after installation, OpenCube attempts a first-run self-repair download.
|
|
77
112
|
- If commands do not appear after installation, restart opencode.
|
|
78
113
|
- OpenCube uses a local-only HTTP server on `127.0.0.1:47832`.
|
|
79
114
|
- If that port is already in use, set `OPENCODE_PET_PORT` before starting opencode.
|
|
115
|
+
- In opencode desktop, OpenCube currently uses a command-abort sentinel to keep local slash commands out of the model flow; depending on opencode version, the desktop UI may show an error toast even though the command was handled.
|
|
80
116
|
|
|
81
117
|
## Local development
|
|
82
118
|
|
package/package.json
CHANGED
package/src/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { app, BrowserWindow, Menu, screen } = require("electron")
|
|
1
|
+
const { app, BrowserWindow, Menu, Tray, nativeImage, screen, ipcMain } = require("electron")
|
|
2
2
|
const fs = require("node:fs")
|
|
3
3
|
const http = require("node:http")
|
|
4
4
|
const os = require("node:os")
|
|
@@ -14,15 +14,37 @@ const STATE_FILE = path.join(DATA_DIR, "state.json")
|
|
|
14
14
|
const PET_HTML_FILE = path.join(DATA_DIR, "pet.html")
|
|
15
15
|
const ICON_PATH = process.env.OPENCODE_PET_ICON || path.join(__dirname, "..", "assets", "opencode-icon.png")
|
|
16
16
|
const MAX_EVENTS = 100
|
|
17
|
+
const MAX_INTERACTION_EVENTS = 80
|
|
17
18
|
const IDLE_TTL_MS = 5 * 60 * 1000
|
|
18
19
|
|
|
19
20
|
let petWindow = null
|
|
20
21
|
let panelWindow = null
|
|
22
|
+
let tray = null
|
|
21
23
|
let server = null
|
|
22
24
|
let events = []
|
|
25
|
+
let interactionEvents = []
|
|
23
26
|
let petSignals = []
|
|
24
27
|
let sessionMap = new Map()
|
|
28
|
+
let activeToolsBySession = new Map()
|
|
25
29
|
let cleanupTimer = null
|
|
30
|
+
let dragState = null
|
|
31
|
+
|
|
32
|
+
ipcMain.on("opencube-drag-start", (event, point) => {
|
|
33
|
+
if (!petWindow || petWindow.isDestroyed()) return
|
|
34
|
+
const [x, y] = petWindow.getPosition()
|
|
35
|
+
dragState = { windowX: x, windowY: y, screenX: point.screenX, screenY: point.screenY }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
ipcMain.on("opencube-drag-move", (event, point) => {
|
|
39
|
+
if (!petWindow || petWindow.isDestroyed() || !dragState) return
|
|
40
|
+
const nextX = Math.round(dragState.windowX + point.screenX - dragState.screenX)
|
|
41
|
+
const nextY = Math.round(dragState.windowY + point.screenY - dragState.screenY)
|
|
42
|
+
petWindow.setPosition(nextX, nextY, false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
ipcMain.on("opencube-drag-end", () => {
|
|
46
|
+
dragState = null
|
|
47
|
+
})
|
|
26
48
|
|
|
27
49
|
function ensureDataDir() {
|
|
28
50
|
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
@@ -108,6 +130,7 @@ function recordEvent(event) {
|
|
|
108
130
|
...event,
|
|
109
131
|
}
|
|
110
132
|
applySessionEvent(item)
|
|
133
|
+
applyToolEvent(item)
|
|
111
134
|
if (item.type === "hello" || item.type === "fancy_hello") {
|
|
112
135
|
petSignals.push({
|
|
113
136
|
id: item.id,
|
|
@@ -125,6 +148,41 @@ function recordEvent(event) {
|
|
|
125
148
|
return item
|
|
126
149
|
}
|
|
127
150
|
|
|
151
|
+
function applyToolEvent(event) {
|
|
152
|
+
if (!event || typeof event.sessionID !== "string" || typeof event.callID !== "string") return
|
|
153
|
+
if (event.type !== "tool.start" && event.type !== "tool.finish") return
|
|
154
|
+
|
|
155
|
+
let tools = activeToolsBySession.get(event.sessionID)
|
|
156
|
+
if (!tools) {
|
|
157
|
+
tools = new Map()
|
|
158
|
+
activeToolsBySession.set(event.sessionID, tools)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (event.type === "tool.start") {
|
|
162
|
+
tools.set(event.callID, {
|
|
163
|
+
callID: event.callID,
|
|
164
|
+
tool: event.tool,
|
|
165
|
+
startedAt: event.receivedAt || Date.now(),
|
|
166
|
+
})
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
tools.delete(event.callID)
|
|
171
|
+
if (tools.size === 0) activeToolsBySession.delete(event.sessionID)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function recordInteractionEvent(event) {
|
|
175
|
+
const item = {
|
|
176
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
177
|
+
receivedAt: Date.now(),
|
|
178
|
+
...event,
|
|
179
|
+
}
|
|
180
|
+
interactionEvents.unshift(item)
|
|
181
|
+
interactionEvents = interactionEvents.slice(0, MAX_INTERACTION_EVENTS)
|
|
182
|
+
updatePanel()
|
|
183
|
+
return item
|
|
184
|
+
}
|
|
185
|
+
|
|
128
186
|
function applySessionEvent(event) {
|
|
129
187
|
if (!event || typeof event.sessionID !== "string") return
|
|
130
188
|
const now = event.receivedAt || Date.now()
|
|
@@ -145,6 +203,7 @@ function applySessionEvent(event) {
|
|
|
145
203
|
}
|
|
146
204
|
|
|
147
205
|
if (event.type === "session.idle") {
|
|
206
|
+
activeToolsBySession.delete(event.sessionID)
|
|
148
207
|
sessionMap.set(event.sessionID, {
|
|
149
208
|
sessionID: event.sessionID,
|
|
150
209
|
state: "idle",
|
|
@@ -165,6 +224,7 @@ function pruneIdleSessions(refresh = true) {
|
|
|
165
224
|
const expiresFrom = session.idleAt || session.lastAt
|
|
166
225
|
if (session.state === "idle" && expiresFrom && now - expiresFrom > IDLE_TTL_MS) {
|
|
167
226
|
sessionMap.delete(sessionID)
|
|
227
|
+
activeToolsBySession.delete(sessionID)
|
|
168
228
|
changed = true
|
|
169
229
|
}
|
|
170
230
|
}
|
|
@@ -186,6 +246,7 @@ function getPetState() {
|
|
|
186
246
|
ball: { size: 14 },
|
|
187
247
|
},
|
|
188
248
|
sessions: Array.from(sessionMap.values()).map((session, index) => ({
|
|
249
|
+
activeTools: Array.from(activeToolsBySession.get(session.sessionID)?.values() || []),
|
|
189
250
|
sessionID: session.sessionID,
|
|
190
251
|
state: session.state,
|
|
191
252
|
busyAt: session.busyAt,
|
|
@@ -230,6 +291,14 @@ function panelHtml() {
|
|
|
230
291
|
})
|
|
231
292
|
.join("")
|
|
232
293
|
|
|
294
|
+
const interactionRows = interactionEvents
|
|
295
|
+
.map((event) => {
|
|
296
|
+
const time = new Date(event.receivedAt).toLocaleTimeString()
|
|
297
|
+
const payload = JSON.stringify(event, null, 2)
|
|
298
|
+
return `<div class="event interaction"><div class="meta interaction-meta">${escapeHtml(time)} · ${escapeHtml(event.type || "interaction")}</div><pre>${escapeHtml(payload)}</pre></div>`
|
|
299
|
+
})
|
|
300
|
+
.join("")
|
|
301
|
+
|
|
233
302
|
return `<!doctype html>
|
|
234
303
|
<html>
|
|
235
304
|
<head>
|
|
@@ -239,17 +308,31 @@ function panelHtml() {
|
|
|
239
308
|
.panel { box-sizing: border-box; width: 100%; height: 100%; padding: 12px; border-radius: 16px; background: rgba(24, 24, 27, 0.94); color: white; box-shadow: 0 14px 42px rgba(0,0,0,.28); overflow: hidden; }
|
|
240
309
|
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 700; }
|
|
241
310
|
.hint { color: rgba(255,255,255,.6); font-size: 11px; font-weight: 500; }
|
|
242
|
-
.
|
|
311
|
+
.columns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: 316px; }
|
|
312
|
+
.section { min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
313
|
+
.section-title { display: flex; justify-content: space-between; color: rgba(255,255,255,.72); font-size: 11px; font-weight: 700; margin: 0 2px 6px; }
|
|
314
|
+
.list { flex: 1; overflow: auto; padding-right: 4px; }
|
|
243
315
|
.empty { color: rgba(255,255,255,.6); font-size: 13px; padding: 18px 4px; }
|
|
244
316
|
.event { border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: rgba(255,255,255,.06); }
|
|
317
|
+
.event.interaction { background: rgba(56,189,248,.08); border-color: rgba(56,189,248,.18); }
|
|
245
318
|
.meta { color: #a7f3d0; font-size: 11px; margin-bottom: 5px; }
|
|
319
|
+
.interaction-meta { color: #93c5fd; }
|
|
246
320
|
pre { margin: 0; color: rgba(255,255,255,.88); white-space: pre-wrap; word-break: break-word; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
247
321
|
</style>
|
|
248
322
|
</head>
|
|
249
323
|
<body>
|
|
250
324
|
<div class="panel">
|
|
251
|
-
<div class="header"><span>
|
|
252
|
-
<div class="
|
|
325
|
+
<div class="header"><span>OpenCube inbox</span><span class="hint">events ${events.length}/${MAX_EVENTS} · input ${interactionEvents.length}/${MAX_INTERACTION_EVENTS}</span></div>
|
|
326
|
+
<div class="columns">
|
|
327
|
+
<section class="section">
|
|
328
|
+
<div class="section-title"><span>HTTP / hook events</span><span>${events.length}</span></div>
|
|
329
|
+
<div class="list">${rows || `<div class="empty">还没有收到事件。</div>`}</div>
|
|
330
|
+
</section>
|
|
331
|
+
<section class="section">
|
|
332
|
+
<div class="section-title"><span>Mouse / keyboard</span><span>${interactionEvents.length}</span></div>
|
|
333
|
+
<div class="list">${interactionRows || `<div class="empty">还没有捕捉到输入事件。</div>`}</div>
|
|
334
|
+
</section>
|
|
335
|
+
</div>
|
|
253
336
|
</div>
|
|
254
337
|
</body>
|
|
255
338
|
</html>`
|
|
@@ -548,7 +631,7 @@ function petHtml3D() {
|
|
|
548
631
|
height: 100%;
|
|
549
632
|
position: relative;
|
|
550
633
|
background: transparent;
|
|
551
|
-
-webkit-app-region: drag;
|
|
634
|
+
-webkit-app-region: no-drag;
|
|
552
635
|
user-select: none;
|
|
553
636
|
}
|
|
554
637
|
#scene {
|
|
@@ -558,11 +641,18 @@ function petHtml3D() {
|
|
|
558
641
|
height: 100%;
|
|
559
642
|
pointer-events: none;
|
|
560
643
|
}
|
|
644
|
+
#hit-layer {
|
|
645
|
+
position: absolute;
|
|
646
|
+
inset: 0;
|
|
647
|
+
background: transparent;
|
|
648
|
+
-webkit-app-region: no-drag;
|
|
649
|
+
}
|
|
561
650
|
</style>
|
|
562
651
|
</head>
|
|
563
652
|
<body>
|
|
564
653
|
<div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
|
|
565
654
|
<canvas id="scene" aria-label="3D opencode pet"></canvas>
|
|
655
|
+
<div id="hit-layer" aria-label="OpenCube interaction layer"></div>
|
|
566
656
|
</div>
|
|
567
657
|
<script>
|
|
568
658
|
window.__PET_BOOT_ERROR = null
|
|
@@ -575,9 +665,11 @@ function petHtml3D() {
|
|
|
575
665
|
</script>
|
|
576
666
|
<script>
|
|
577
667
|
const THREE = require(${threeCjsPath})
|
|
668
|
+
const { ipcRenderer } = require("electron")
|
|
578
669
|
|
|
579
670
|
window.__PET_STATE = ${initialStateJson}
|
|
580
671
|
const stage = document.querySelector(".stage")
|
|
672
|
+
const hitLayer = document.getElementById("hit-layer")
|
|
581
673
|
const faceOrder = ["front", "right", "top", "back", "left", "bottom"]
|
|
582
674
|
const sessionFaceMap = new Map()
|
|
583
675
|
const sessionColorMap = new Map()
|
|
@@ -593,6 +685,10 @@ function petHtml3D() {
|
|
|
593
685
|
let torque = { x: 0, y: 0, z: 0 }
|
|
594
686
|
let nextTorqueAt = 0
|
|
595
687
|
let wasBusy = false
|
|
688
|
+
let frictionHoldActive = false
|
|
689
|
+
let frictionHoldLevel = 0
|
|
690
|
+
let leftDragActive = false
|
|
691
|
+
let dragEmitAccumulator = 0
|
|
596
692
|
let latestDebug = { now: Date.now(), busy: 0, rotation, angularVelocity, torque, speed: 0, faceRotations: {} }
|
|
597
693
|
|
|
598
694
|
const canvas = document.getElementById("scene")
|
|
@@ -606,6 +702,11 @@ function petHtml3D() {
|
|
|
606
702
|
|
|
607
703
|
const cubeGroup = new THREE.Group()
|
|
608
704
|
scene.add(cubeGroup)
|
|
705
|
+
const dragParticleGroup = new THREE.Group()
|
|
706
|
+
dragParticleGroup.position.z = 0.88
|
|
707
|
+
scene.add(dragParticleGroup)
|
|
708
|
+
const toolParticleGroup = new THREE.Group()
|
|
709
|
+
scene.add(toolParticleGroup)
|
|
609
710
|
const iconTexture = new THREE.TextureLoader().load("${iconUrl}")
|
|
610
711
|
iconTexture.colorSpace = THREE.SRGBColorSpace
|
|
611
712
|
iconTexture.generateMipmaps = false
|
|
@@ -619,6 +720,7 @@ function petHtml3D() {
|
|
|
619
720
|
side: THREE.DoubleSide,
|
|
620
721
|
})
|
|
621
722
|
const glowTexture = createGlowTexture()
|
|
723
|
+
const dragParticleTexture = createDragParticleTexture()
|
|
622
724
|
const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
|
|
623
725
|
const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
|
|
624
726
|
const rad = THREE.MathUtils.degToRad
|
|
@@ -641,6 +743,247 @@ function petHtml3D() {
|
|
|
641
743
|
return texture
|
|
642
744
|
}
|
|
643
745
|
|
|
746
|
+
function createDragParticleTexture() {
|
|
747
|
+
const canvas = document.createElement("canvas")
|
|
748
|
+
canvas.width = 96
|
|
749
|
+
canvas.height = 96
|
|
750
|
+
const ctx = canvas.getContext("2d")
|
|
751
|
+
const gradient = ctx.createRadialGradient(48, 48, 1, 48, 48, 45)
|
|
752
|
+
gradient.addColorStop(0, "rgba(255,255,255,1)")
|
|
753
|
+
gradient.addColorStop(0.24, "rgba(255,255,255,1)")
|
|
754
|
+
gradient.addColorStop(0.52, "rgba(255,255,255,.52)")
|
|
755
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)")
|
|
756
|
+
ctx.fillStyle = gradient
|
|
757
|
+
ctx.fillRect(0, 0, 96, 96)
|
|
758
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
759
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
760
|
+
return texture
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const dragParticles = []
|
|
764
|
+
const toolParticles = []
|
|
765
|
+
const toolEmitAccumulators = new Map()
|
|
766
|
+
const faceVectors = {
|
|
767
|
+
front: { position: new THREE.Vector3(0, 0, 0.34), normal: new THREE.Vector3(0, 0, 1) },
|
|
768
|
+
back: { position: new THREE.Vector3(0, 0, -0.34), normal: new THREE.Vector3(0, 0, -1) },
|
|
769
|
+
right: { position: new THREE.Vector3(0.34, 0, 0), normal: new THREE.Vector3(1, 0, 0) },
|
|
770
|
+
left: { position: new THREE.Vector3(-0.34, 0, 0), normal: new THREE.Vector3(-1, 0, 0) },
|
|
771
|
+
top: { position: new THREE.Vector3(0, 0.34, 0), normal: new THREE.Vector3(0, 1, 0) },
|
|
772
|
+
bottom: { position: new THREE.Vector3(0, -0.34, 0), normal: new THREE.Vector3(0, -1, 0) },
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function createDragParticles() {
|
|
776
|
+
const geometry = new THREE.PlaneGeometry(0.086, 0.086)
|
|
777
|
+
for (let index = 0; index < 34; index++) {
|
|
778
|
+
const material = new THREE.MeshBasicMaterial({
|
|
779
|
+
map: dragParticleTexture,
|
|
780
|
+
color: new THREE.Color(1, 1, 1),
|
|
781
|
+
transparent: true,
|
|
782
|
+
opacity: 0,
|
|
783
|
+
blending: THREE.AdditiveBlending,
|
|
784
|
+
depthWrite: false,
|
|
785
|
+
side: THREE.DoubleSide,
|
|
786
|
+
})
|
|
787
|
+
const particle = new THREE.Mesh(geometry, material)
|
|
788
|
+
particle.userData = {
|
|
789
|
+
active: false,
|
|
790
|
+
life: 0,
|
|
791
|
+
maxLife: 1,
|
|
792
|
+
vx: 0,
|
|
793
|
+
vy: 0,
|
|
794
|
+
size: randomBetween(0.72, 1.28),
|
|
795
|
+
}
|
|
796
|
+
dragParticleGroup.add(particle)
|
|
797
|
+
dragParticles.push(particle)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function createToolParticles() {
|
|
802
|
+
for (let index = 0; index < 56; index++) {
|
|
803
|
+
const material = new THREE.SpriteMaterial({
|
|
804
|
+
map: dragParticleTexture,
|
|
805
|
+
color: 0xffffff,
|
|
806
|
+
transparent: true,
|
|
807
|
+
opacity: 0,
|
|
808
|
+
blending: THREE.AdditiveBlending,
|
|
809
|
+
depthWrite: false,
|
|
810
|
+
})
|
|
811
|
+
const particle = new THREE.Sprite(material)
|
|
812
|
+
particle.userData = {
|
|
813
|
+
active: false,
|
|
814
|
+
life: 0,
|
|
815
|
+
maxLife: 1,
|
|
816
|
+
velocity: new THREE.Vector3(),
|
|
817
|
+
size: 0.06,
|
|
818
|
+
}
|
|
819
|
+
toolParticleGroup.add(particle)
|
|
820
|
+
toolParticles.push(particle)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function colorToThree(color) {
|
|
825
|
+
return new THREE.Color((color?.r ?? 255) / 255, (color?.g ?? 255) / 255, (color?.b ?? 255) / 255)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function emitToolParticle(faceName, color) {
|
|
829
|
+
const face = faceVectors[faceName]
|
|
830
|
+
if (!face) return false
|
|
831
|
+
const particle = toolParticles.find((item) => !item.userData.active)
|
|
832
|
+
if (!particle) return false
|
|
833
|
+
|
|
834
|
+
const origin = face.position.clone()
|
|
835
|
+
const normal = face.normal.clone()
|
|
836
|
+
cubeGroup.localToWorld(origin)
|
|
837
|
+
normal.applyQuaternion(cubeGroup.quaternion).normalize()
|
|
838
|
+
|
|
839
|
+
const basis = Math.abs(normal.y) > 0.82 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)
|
|
840
|
+
const tangentA = new THREE.Vector3().crossVectors(normal, basis).normalize()
|
|
841
|
+
const tangentB = new THREE.Vector3().crossVectors(normal, tangentA).normalize()
|
|
842
|
+
|
|
843
|
+
const data = particle.userData
|
|
844
|
+
data.active = true
|
|
845
|
+
data.life = 0
|
|
846
|
+
data.maxLife = randomBetween(0.45, 0.78)
|
|
847
|
+
data.size = randomBetween(0.062, 0.115)
|
|
848
|
+
data.velocity.copy(normal).multiplyScalar(randomBetween(1.45, 2.35))
|
|
849
|
+
data.velocity.add(tangentA.multiplyScalar(randomBetween(-0.32, 0.32)))
|
|
850
|
+
data.velocity.add(tangentB.multiplyScalar(randomBetween(-0.32, 0.32)))
|
|
851
|
+
particle.position.copy(origin).add(normal.clone().multiplyScalar(0.035))
|
|
852
|
+
particle.scale.setScalar(data.size)
|
|
853
|
+
particle.material.color.copy(colorToThree(color))
|
|
854
|
+
particle.material.opacity = 0.92
|
|
855
|
+
return true
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function updateToolParticles(sessions, dt) {
|
|
859
|
+
const activeToolSessions = sessions.filter((session) => session.state === "busy" && session.activeTools?.length > 0)
|
|
860
|
+
const activeIDs = new Set(activeToolSessions.map((session) => session.sessionID))
|
|
861
|
+
for (const sessionID of Array.from(toolEmitAccumulators.keys())) {
|
|
862
|
+
if (!activeIDs.has(sessionID)) toolEmitAccumulators.delete(sessionID)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
for (const session of activeToolSessions) {
|
|
866
|
+
const faceName = sessionFaceMap.get(session.sessionID)
|
|
867
|
+
if (!faceName) continue
|
|
868
|
+
const color = sessionColorMap.get(session.sessionID) || randomSessionGlowColor()
|
|
869
|
+
const jitterRate = randomBetween(7.5, 11.5)
|
|
870
|
+
const next = (toolEmitAccumulators.get(session.sessionID) || 0) + dt * jitterRate
|
|
871
|
+
let accumulator = next
|
|
872
|
+
while (accumulator >= 1) {
|
|
873
|
+
if (!emitToolParticle(faceName, color)) break
|
|
874
|
+
accumulator -= 1
|
|
875
|
+
}
|
|
876
|
+
toolEmitAccumulators.set(session.sessionID, accumulator)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let activeCount = 0
|
|
880
|
+
for (const particle of toolParticles) {
|
|
881
|
+
const data = particle.userData
|
|
882
|
+
if (!data.active) {
|
|
883
|
+
particle.material.opacity = 0
|
|
884
|
+
continue
|
|
885
|
+
}
|
|
886
|
+
data.life += dt
|
|
887
|
+
if (data.life >= data.maxLife) {
|
|
888
|
+
data.active = false
|
|
889
|
+
particle.material.opacity = 0
|
|
890
|
+
continue
|
|
891
|
+
}
|
|
892
|
+
activeCount += 1
|
|
893
|
+
const age = data.life / data.maxLife
|
|
894
|
+
particle.position.addScaledVector(data.velocity, dt)
|
|
895
|
+
particle.scale.setScalar(data.size * (1.02 + age * 0.42))
|
|
896
|
+
particle.material.opacity = Math.min(1, 0.24 + Math.sin(age * Math.PI) * 1.18)
|
|
897
|
+
}
|
|
898
|
+
toolParticleGroup.visible = activeCount > 0 || activeToolSessions.length > 0
|
|
899
|
+
return { activeSessions: activeToolSessions.length, activeCount }
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function activeDragColors() {
|
|
903
|
+
const colors = []
|
|
904
|
+
for (const color of sessionColorMap.values()) {
|
|
905
|
+
if (color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) colors.push(color)
|
|
906
|
+
}
|
|
907
|
+
return colors
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function monochromeDragColor(index, cycle) {
|
|
911
|
+
const value = 0.26 + ((index % 5) / 4) * 0.56 + Math.sin(cycle * Math.PI) * 0.12
|
|
912
|
+
const clamped = Math.max(0.18, Math.min(0.92, value))
|
|
913
|
+
return { r: Math.round(clamped * 255), g: Math.round(clamped * 255), b: Math.round(clamped * 255) }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function chooseDragParticleColor(index, palette) {
|
|
917
|
+
if (palette.length > 0) return palette[index % palette.length]
|
|
918
|
+
const value = index % 2 === 0 ? 238 : 32
|
|
919
|
+
return { r: value, g: value, b: value }
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function emitDragParticle(palette) {
|
|
923
|
+
const particle = dragParticles.find((item) => !item.userData.active)
|
|
924
|
+
if (!particle) return false
|
|
925
|
+
const data = particle.userData
|
|
926
|
+
const angle = randomBetween(0, Math.PI * 2)
|
|
927
|
+
const startRadius = randomBetween(0.16, 0.30)
|
|
928
|
+
const velocity = randomBetween(0.10, 0.20)
|
|
929
|
+
const tangent = randomBetween(-0.045, 0.045)
|
|
930
|
+
const color = chooseDragParticleColor(Math.floor(randomBetween(0, 1000)), palette)
|
|
931
|
+
data.active = true
|
|
932
|
+
data.life = 0
|
|
933
|
+
data.maxLife = randomBetween(1.15, 1.9)
|
|
934
|
+
data.vx = Math.cos(angle) * velocity + Math.cos(angle + Math.PI / 2) * tangent
|
|
935
|
+
data.vy = Math.sin(angle) * velocity + Math.sin(angle + Math.PI / 2) * tangent
|
|
936
|
+
data.size = randomBetween(0.92, 1.52)
|
|
937
|
+
particle.position.x = Math.cos(angle) * startRadius
|
|
938
|
+
particle.position.y = Math.sin(angle) * startRadius
|
|
939
|
+
particle.position.z = 0
|
|
940
|
+
particle.scale.setScalar(data.size)
|
|
941
|
+
particle.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
|
|
942
|
+
particle.material.opacity = 0.96
|
|
943
|
+
return true
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function updateDragParticles(now, holdLevel, speed, dt) {
|
|
947
|
+
const hold = Math.max(0, Math.min(1, holdLevel / 11))
|
|
948
|
+
const intensity = hold
|
|
949
|
+
const palette = activeDragColors()
|
|
950
|
+
if (hold > 0.02) {
|
|
951
|
+
const emitRate = 7.5 - hold * 5.2
|
|
952
|
+
dragEmitAccumulator += dt * emitRate
|
|
953
|
+
while (dragEmitAccumulator >= 1) {
|
|
954
|
+
if (!emitDragParticle(palette)) break
|
|
955
|
+
dragEmitAccumulator -= 1
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
dragEmitAccumulator = 0
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let activeCount = 0
|
|
962
|
+
for (const particle of dragParticles) {
|
|
963
|
+
const data = particle.userData
|
|
964
|
+
if (!data.active) {
|
|
965
|
+
particle.material.opacity = 0
|
|
966
|
+
continue
|
|
967
|
+
}
|
|
968
|
+
data.life += dt
|
|
969
|
+
if (data.life >= data.maxLife) {
|
|
970
|
+
data.active = false
|
|
971
|
+
particle.material.opacity = 0
|
|
972
|
+
continue
|
|
973
|
+
}
|
|
974
|
+
activeCount += 1
|
|
975
|
+
const age = data.life / data.maxLife
|
|
976
|
+
particle.position.x += data.vx * dt
|
|
977
|
+
particle.position.y += data.vy * dt
|
|
978
|
+
const fade = Math.sin(age * Math.PI)
|
|
979
|
+
const scale = data.size * (0.92 + age * 0.58)
|
|
980
|
+
particle.scale.setScalar(scale)
|
|
981
|
+
particle.material.opacity = Math.min(1, 0.12 + fade * 0.98)
|
|
982
|
+
}
|
|
983
|
+
dragParticleGroup.visible = activeCount > 0 || intensity > 0.01
|
|
984
|
+
return { intensity, activeCount, emitRate: hold > 0.02 ? 7.5 - hold * 5.2 : 0 }
|
|
985
|
+
}
|
|
986
|
+
|
|
644
987
|
function resize() {
|
|
645
988
|
const width = Math.max(1, window.innerWidth)
|
|
646
989
|
const height = Math.max(1, window.innerHeight)
|
|
@@ -651,6 +994,113 @@ function petHtml3D() {
|
|
|
651
994
|
window.addEventListener("resize", resize)
|
|
652
995
|
resize()
|
|
653
996
|
|
|
997
|
+
function postInteraction(event) {
|
|
998
|
+
fetch("http://${HOST}:${PORT}/interaction", {
|
|
999
|
+
method: "POST",
|
|
1000
|
+
headers: { "content-type": "application/json" },
|
|
1001
|
+
body: JSON.stringify({
|
|
1002
|
+
...event,
|
|
1003
|
+
source: "opencube-renderer",
|
|
1004
|
+
frictionHoldActive,
|
|
1005
|
+
frictionHoldLevel,
|
|
1006
|
+
frictionHoldMultiplier: 1 + frictionHoldLevel,
|
|
1007
|
+
}),
|
|
1008
|
+
}).catch(() => {})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function pointerPayload(type, event) {
|
|
1012
|
+
return {
|
|
1013
|
+
type,
|
|
1014
|
+
nativeType: event.type,
|
|
1015
|
+
pointerId: event.pointerId,
|
|
1016
|
+
pointerType: event.pointerType,
|
|
1017
|
+
button: event.button,
|
|
1018
|
+
buttons: event.buttons,
|
|
1019
|
+
screenX: Math.round(event.screenX || 0),
|
|
1020
|
+
screenY: Math.round(event.screenY || 0),
|
|
1021
|
+
clientX: Math.round(event.clientX),
|
|
1022
|
+
clientY: Math.round(event.clientY),
|
|
1023
|
+
altKey: event.altKey,
|
|
1024
|
+
ctrlKey: event.ctrlKey,
|
|
1025
|
+
metaKey: event.metaKey,
|
|
1026
|
+
shiftKey: event.shiftKey,
|
|
1027
|
+
target: "cube",
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function beginFrictionHold(event) {
|
|
1032
|
+
if (event.button !== 2) return
|
|
1033
|
+
event.preventDefault()
|
|
1034
|
+
try { hitLayer.setPointerCapture(event.pointerId) } catch {}
|
|
1035
|
+
frictionHoldActive = true
|
|
1036
|
+
postInteraction(pointerPayload("mouse.right.down", event))
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function endFrictionHold(event) {
|
|
1040
|
+
if (event && event.button !== undefined && event.button !== 2) return
|
|
1041
|
+
if (frictionHoldActive) postInteraction(pointerPayload("mouse.right.up", event || { button: 2, buttons: 0, clientX: 0, clientY: 0 }))
|
|
1042
|
+
frictionHoldActive = false
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function beginLeftDrag(event) {
|
|
1046
|
+
if (event.button !== 0) return
|
|
1047
|
+
event.preventDefault()
|
|
1048
|
+
try { hitLayer.setPointerCapture(event.pointerId) } catch {}
|
|
1049
|
+
leftDragActive = true
|
|
1050
|
+
ipcRenderer.send("opencube-drag-start", { screenX: event.screenX, screenY: event.screenY })
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function moveLeftDrag(event) {
|
|
1054
|
+
if (!leftDragActive) return
|
|
1055
|
+
event.preventDefault()
|
|
1056
|
+
ipcRenderer.send("opencube-drag-move", { screenX: event.screenX, screenY: event.screenY })
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function endLeftDrag(event) {
|
|
1060
|
+
if (!leftDragActive) return
|
|
1061
|
+
leftDragActive = false
|
|
1062
|
+
ipcRenderer.send("opencube-drag-end")
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function updateFrictionHold(dt) {
|
|
1066
|
+
const growPerSecond = 2.4
|
|
1067
|
+
const recoverPerSecond = 8.0
|
|
1068
|
+
if (frictionHoldActive) {
|
|
1069
|
+
frictionHoldLevel = Math.min(11, frictionHoldLevel + growPerSecond * dt)
|
|
1070
|
+
} else {
|
|
1071
|
+
frictionHoldLevel = Math.max(0, frictionHoldLevel - recoverPerSecond * dt)
|
|
1072
|
+
}
|
|
1073
|
+
return 1 + frictionHoldLevel
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
hitLayer.addEventListener("pointerdown", (event) => {
|
|
1077
|
+
if (event.button === 0) beginLeftDrag(event)
|
|
1078
|
+
if (event.button === 2) beginFrictionHold(event)
|
|
1079
|
+
})
|
|
1080
|
+
hitLayer.addEventListener("pointermove", moveLeftDrag)
|
|
1081
|
+
hitLayer.addEventListener("pointerup", (event) => {
|
|
1082
|
+
if (event.button === 0) endLeftDrag(event)
|
|
1083
|
+
if (event.button === 2) endFrictionHold(event)
|
|
1084
|
+
})
|
|
1085
|
+
hitLayer.addEventListener("pointercancel", (event) => {
|
|
1086
|
+
endLeftDrag(event)
|
|
1087
|
+
endFrictionHold(event)
|
|
1088
|
+
})
|
|
1089
|
+
hitLayer.addEventListener("contextmenu", (event) => {
|
|
1090
|
+
event.preventDefault()
|
|
1091
|
+
postInteraction(pointerPayload("mouse.right.contextmenu", event))
|
|
1092
|
+
})
|
|
1093
|
+
window.addEventListener("keydown", (event) => postInteraction({ type: "keyboard.down", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
|
|
1094
|
+
window.addEventListener("keyup", (event) => postInteraction({ type: "keyboard.up", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
|
|
1095
|
+
document.addEventListener("mouseup", endFrictionHold)
|
|
1096
|
+
window.addEventListener("mouseup", endFrictionHold)
|
|
1097
|
+
window.addEventListener("pointerup", endFrictionHold)
|
|
1098
|
+
window.addEventListener("pointercancel", () => endFrictionHold())
|
|
1099
|
+
window.addEventListener("blur", () => {
|
|
1100
|
+
endLeftDrag()
|
|
1101
|
+
endFrictionHold()
|
|
1102
|
+
})
|
|
1103
|
+
|
|
654
1104
|
function randomBetween(min, max) {
|
|
655
1105
|
return min + Math.random() * (max - min)
|
|
656
1106
|
}
|
|
@@ -722,6 +1172,8 @@ function petHtml3D() {
|
|
|
722
1172
|
createFace("left", [-0.30, 0, 0], [0, -Math.PI / 2, rad(faceRotations.left || 0)], [-0.314, 0, 0])
|
|
723
1173
|
createFace("top", [0, 0.30, 0], [-Math.PI / 2, 0, rad(faceRotations.top || 0)], [0, 0.314, 0])
|
|
724
1174
|
createFace("bottom", [0, -0.30, 0], [Math.PI / 2, 0, rad(faceRotations.bottom || 0)], [0, -0.314, 0])
|
|
1175
|
+
createDragParticles()
|
|
1176
|
+
createToolParticles()
|
|
725
1177
|
|
|
726
1178
|
function createFace(name, position, rotation, glowPosition) {
|
|
727
1179
|
const face = new THREE.Mesh(faceGeometry, iconMaterial.clone())
|
|
@@ -949,15 +1401,20 @@ function petHtml3D() {
|
|
|
949
1401
|
const busyCount = sessions.filter((session) => session.state === "busy").length
|
|
950
1402
|
const isBusy = busyCount > 0
|
|
951
1403
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1404
|
+
// Keep the same randomized torque model in both busy and idle modes.
|
|
1405
|
+
// Busy uses low friction, so the cube accelerates into active motion.
|
|
1406
|
+
// Idle keeps receiving random torque, but high friction turns it into a
|
|
1407
|
+
// subtle breathing/drifting motion instead of stopping completely.
|
|
1408
|
+
if (isBusy !== wasBusy) setNextTorque(now)
|
|
1409
|
+
if (now >= nextTorqueAt) setNextTorque(now)
|
|
955
1410
|
wasBusy = isBusy
|
|
956
1411
|
stage.classList.toggle("has-busy", isBusy)
|
|
957
1412
|
stage.classList.toggle("has-sessions", sessions.length > 0)
|
|
958
1413
|
|
|
959
1414
|
const inertia = 1.18
|
|
960
|
-
const
|
|
1415
|
+
const baseFriction = isBusy ? 0.58 : 2.85
|
|
1416
|
+
const holdMultiplier = updateFrictionHold(dt)
|
|
1417
|
+
const friction = baseFriction * holdMultiplier
|
|
961
1418
|
angularVelocity.x += (torque.x / inertia) * dt
|
|
962
1419
|
angularVelocity.y += (torque.y / inertia) * dt
|
|
963
1420
|
angularVelocity.z += (torque.z / inertia) * dt
|
|
@@ -970,6 +1427,10 @@ function petHtml3D() {
|
|
|
970
1427
|
rotation.x += angularVelocity.x * dt
|
|
971
1428
|
rotation.y += angularVelocity.y * dt
|
|
972
1429
|
rotation.z += angularVelocity.z * dt
|
|
1430
|
+
cubeGroup.rotation.x = rad(rotation.x)
|
|
1431
|
+
cubeGroup.rotation.y = rad(rotation.y)
|
|
1432
|
+
cubeGroup.rotation.z = rad(rotation.z)
|
|
1433
|
+
cubeGroup.updateMatrixWorld(true)
|
|
973
1434
|
|
|
974
1435
|
const speed = magnitude(angularVelocity)
|
|
975
1436
|
const speedRatio = Math.min(1, speed / 1400)
|
|
@@ -977,20 +1438,35 @@ function petHtml3D() {
|
|
|
977
1438
|
const glowR = Math.round(92 + (0 - 92) * glow)
|
|
978
1439
|
const glowG = Math.round(255 + (190 - 255) * glow)
|
|
979
1440
|
const glowB = Math.round(232 + (210 - 232) * glow)
|
|
1441
|
+
const dragParticles = updateDragParticles(now, frictionHoldLevel, speed, dt)
|
|
980
1442
|
const busyFaces = syncBusyFaces(sessions, speed)
|
|
1443
|
+
const toolParticles = updateToolParticles(sessions, dt)
|
|
981
1444
|
processSignals(snapshot.signals || [], now)
|
|
982
1445
|
const helloFlashes = applyFlashFaces(now)
|
|
983
1446
|
renderCube()
|
|
984
1447
|
latestDebug = {
|
|
985
1448
|
now: Date.now(),
|
|
986
1449
|
busy: busyCount,
|
|
1450
|
+
mode: isBusy ? "busy" : "idle",
|
|
1451
|
+
sessions: sessions.map((session) => ({
|
|
1452
|
+
sessionID: session.sessionID,
|
|
1453
|
+
state: session.state,
|
|
1454
|
+
activeTools: session.activeTools || [],
|
|
1455
|
+
})),
|
|
987
1456
|
rotation: { ...rotation },
|
|
988
1457
|
angularVelocity: { ...angularVelocity },
|
|
989
1458
|
torque: { ...torque },
|
|
1459
|
+
friction,
|
|
1460
|
+
baseFriction,
|
|
1461
|
+
frictionHoldActive,
|
|
1462
|
+
frictionHoldMultiplier: holdMultiplier,
|
|
1463
|
+
frictionHoldLevel,
|
|
990
1464
|
speed,
|
|
991
1465
|
speedRatio,
|
|
992
1466
|
nextTorqueAt,
|
|
993
1467
|
glow,
|
|
1468
|
+
dragParticles,
|
|
1469
|
+
toolParticles,
|
|
994
1470
|
colorReleaseSpeed,
|
|
995
1471
|
glowColor: { r: glowR, g: glowG, b: glowB },
|
|
996
1472
|
faceRotations,
|
|
@@ -1059,11 +1535,9 @@ function createPetWindow() {
|
|
|
1059
1535
|
})
|
|
1060
1536
|
petWindow.setAlwaysOnTop(true, "floating")
|
|
1061
1537
|
petWindow.loadFile(writePetHtmlFile())
|
|
1062
|
-
petWindow.webContents.on("context-menu", () => buildMenu().popup({ window: petWindow }))
|
|
1063
1538
|
petWindow.on("moved", () => {
|
|
1064
1539
|
const [x, y] = petWindow.getPosition()
|
|
1065
1540
|
writeState({ visible: true, x, y })
|
|
1066
|
-
positionPanel()
|
|
1067
1541
|
})
|
|
1068
1542
|
petWindow.on("closed", () => {
|
|
1069
1543
|
petWindow = null
|
|
@@ -1076,8 +1550,8 @@ function createPanelWindow() {
|
|
|
1076
1550
|
if (panelWindow && !panelWindow.isDestroyed()) return panelWindow
|
|
1077
1551
|
|
|
1078
1552
|
panelWindow = new BrowserWindow({
|
|
1079
|
-
width:
|
|
1080
|
-
height:
|
|
1553
|
+
width: 680,
|
|
1554
|
+
height: 420,
|
|
1081
1555
|
show: false,
|
|
1082
1556
|
frame: false,
|
|
1083
1557
|
resizable: false,
|
|
@@ -1100,14 +1574,28 @@ function createPanelWindow() {
|
|
|
1100
1574
|
}
|
|
1101
1575
|
|
|
1102
1576
|
function positionPanel() {
|
|
1103
|
-
if (!
|
|
1104
|
-
const [x, y] = petWindow.getPosition()
|
|
1105
|
-
const petBounds = petWindow.getBounds()
|
|
1577
|
+
if (!panelWindow || panelWindow.isDestroyed()) return
|
|
1106
1578
|
const panelBounds = panelWindow.getBounds()
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1579
|
+
const anchor = tray && !tray.isDestroyed() ? tray.getBounds() : undefined
|
|
1580
|
+
const display = anchor
|
|
1581
|
+
? screen.getDisplayNearestPoint({ x: anchor.x, y: anchor.y }).workArea
|
|
1582
|
+
: screen.getPrimaryDisplay().workArea
|
|
1583
|
+
|
|
1584
|
+
let nextX = anchor
|
|
1585
|
+
? Math.round(anchor.x + anchor.width / 2 - panelBounds.width / 2)
|
|
1586
|
+
: display.x + display.width - panelBounds.width - 16
|
|
1587
|
+
let nextY = anchor
|
|
1588
|
+
? Math.round(anchor.y + anchor.height + 8)
|
|
1589
|
+
: display.y + 36
|
|
1590
|
+
|
|
1591
|
+
// If the menu bar reports a coordinate outside the workArea, keep the panel
|
|
1592
|
+
// as a standalone top-right popover instead of attaching it to the cube.
|
|
1593
|
+
if (!anchor || nextY < display.y || nextY > display.y + display.height) {
|
|
1594
|
+
nextX = display.x + display.width - panelBounds.width - 16
|
|
1595
|
+
nextY = display.y + 36
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if (nextX < display.x) nextX = display.x + 8
|
|
1111
1599
|
if (nextX + panelBounds.width > display.x + display.width) nextX = display.x + display.width - panelBounds.width - 8
|
|
1112
1600
|
if (nextY + panelBounds.height > display.y + display.height) nextY = display.y + display.height - panelBounds.height - 8
|
|
1113
1601
|
if (nextY < display.y) nextY = display.y + 8
|
|
@@ -1115,7 +1603,6 @@ function positionPanel() {
|
|
|
1115
1603
|
}
|
|
1116
1604
|
|
|
1117
1605
|
function showPanel() {
|
|
1118
|
-
showPet()
|
|
1119
1606
|
const win = createPanelWindow()
|
|
1120
1607
|
updatePanel()
|
|
1121
1608
|
positionPanel()
|
|
@@ -1147,15 +1634,32 @@ function hidePet() {
|
|
|
1147
1634
|
|
|
1148
1635
|
function buildMenu() {
|
|
1149
1636
|
return Menu.buildFromTemplate([
|
|
1150
|
-
{ label: "Show
|
|
1637
|
+
{ label: "Show OpenCube", click: showPet },
|
|
1638
|
+
{ label: "Hide OpenCube", click: hidePet },
|
|
1639
|
+
{ type: "separator" },
|
|
1151
1640
|
{ label: "Show Inbox", click: showPanel },
|
|
1152
|
-
{ label: "Hide Pet", click: hidePet },
|
|
1153
1641
|
{ label: "Hide Inbox", click: hidePanel },
|
|
1154
1642
|
{ type: "separator" },
|
|
1155
|
-
{ label: "Quit
|
|
1643
|
+
{ label: "Quit OpenCube", click: () => app.quit() },
|
|
1156
1644
|
])
|
|
1157
1645
|
}
|
|
1158
1646
|
|
|
1647
|
+
function createTray() {
|
|
1648
|
+
if (tray) return tray
|
|
1649
|
+
let image = nativeImage.createFromPath(ICON_PATH)
|
|
1650
|
+
if (image.isEmpty()) {
|
|
1651
|
+
image = nativeImage.createFromDataURL(
|
|
1652
|
+
"data:image/svg+xml;utf8," +
|
|
1653
|
+
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="4" width="24" height="24" rx="6" fill="#111"/><rect x="11" y="8" width="10" height="16" rx="2" fill="#fff"/><rect x="14" y="12" width="5" height="9" rx="1" fill="#111"/></svg>`),
|
|
1654
|
+
)
|
|
1655
|
+
}
|
|
1656
|
+
image = image.resize({ width: 18, height: 18 })
|
|
1657
|
+
tray = new Tray(image)
|
|
1658
|
+
tray.setToolTip("OpenCube")
|
|
1659
|
+
tray.setContextMenu(buildMenu())
|
|
1660
|
+
return tray
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1159
1663
|
function startServer() {
|
|
1160
1664
|
if (server) return
|
|
1161
1665
|
server = http.createServer(async (req, res) => {
|
|
@@ -1208,6 +1712,12 @@ function startServer() {
|
|
|
1208
1712
|
return json(res, 200, { ok: true, event: item })
|
|
1209
1713
|
}
|
|
1210
1714
|
|
|
1715
|
+
if (req.method === "POST" && url.pathname === "/interaction") {
|
|
1716
|
+
const body = await readRequestJson(req)
|
|
1717
|
+
const item = recordInteractionEvent(body)
|
|
1718
|
+
return json(res, 200, { ok: true, event: item })
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1211
1721
|
if (req.method === "POST" && url.pathname === "/show") {
|
|
1212
1722
|
showPet()
|
|
1213
1723
|
return json(res, 200, { ok: true })
|
|
@@ -1238,6 +1748,7 @@ function startServer() {
|
|
|
1238
1748
|
function start() {
|
|
1239
1749
|
app.dock?.hide()
|
|
1240
1750
|
writeState({ visible: false, mode: "floating" })
|
|
1751
|
+
createTray()
|
|
1241
1752
|
startServer()
|
|
1242
1753
|
cleanupTimer = setInterval(() => pruneIdleSessions(true), 30 * 1000)
|
|
1243
1754
|
cleanupTimer.unref?.()
|
package/src/plugin-server.cjs
CHANGED
|
@@ -47,6 +47,17 @@ function cubNotice(text, icon = CUB_ICON) {
|
|
|
47
47
|
return `${icon} ${text}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function summarizeToolOutput(output) {
|
|
51
|
+
if (!output || typeof output !== "object") return undefined
|
|
52
|
+
const text = typeof output.output === "string" ? output.output : undefined
|
|
53
|
+
return {
|
|
54
|
+
title: output.title,
|
|
55
|
+
output: text && text.length > 2000 ? `${text.slice(0, 2000)}…` : text,
|
|
56
|
+
outputLength: text?.length,
|
|
57
|
+
metadata: output.metadata,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
module.exports = {
|
|
51
62
|
id: "opencube",
|
|
52
63
|
server: async ({ client }) => {
|
|
@@ -164,6 +175,31 @@ module.exports = {
|
|
|
164
175
|
source: "opencube-plugin",
|
|
165
176
|
})
|
|
166
177
|
},
|
|
178
|
+
|
|
179
|
+
"tool.execute.before": async (input, output) => {
|
|
180
|
+
await sendEvent({
|
|
181
|
+
type: "tool.start",
|
|
182
|
+
message: `tool ${input.tool} started`,
|
|
183
|
+
sessionID: input.sessionID,
|
|
184
|
+
tool: input.tool,
|
|
185
|
+
callID: input.callID,
|
|
186
|
+
args: output?.args,
|
|
187
|
+
source: "opencube-plugin",
|
|
188
|
+
})
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
"tool.execute.after": async (input, output) => {
|
|
192
|
+
await sendEvent({
|
|
193
|
+
type: "tool.finish",
|
|
194
|
+
message: `tool ${input.tool} finished`,
|
|
195
|
+
sessionID: input.sessionID,
|
|
196
|
+
tool: input.tool,
|
|
197
|
+
callID: input.callID,
|
|
198
|
+
args: input.args,
|
|
199
|
+
result: summarizeToolOutput(output),
|
|
200
|
+
source: "opencube-plugin",
|
|
201
|
+
})
|
|
202
|
+
},
|
|
167
203
|
}
|
|
168
204
|
},
|
|
169
205
|
}
|