watercooler 0.0.1
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/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/watercooler.js +18 -0
- package/package.json +51 -0
- package/public/app.js +707 -0
- package/public/index.html +516 -0
- package/server.ts +192 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
3
|
+
|
|
4
|
+
// State
|
|
5
|
+
let config = { user: '', mailbox: '' };
|
|
6
|
+
let messages = []; // Messages TO user (for main panel)
|
|
7
|
+
let allMessages = []; // All messages involving user (for house dialogs)
|
|
8
|
+
let recipients = [];
|
|
9
|
+
let scene, camera, renderer, controls;
|
|
10
|
+
let agentMeshes = new Map();
|
|
11
|
+
let connectionLines = [];
|
|
12
|
+
let raycaster, mouse;
|
|
13
|
+
|
|
14
|
+
// Color palette for agents
|
|
15
|
+
const agentColors = [
|
|
16
|
+
0xFF6B6B, 0x4ECDC4, 0x45B7D1, 0xFFA07A, 0x98D8C8,
|
|
17
|
+
0xF7DC6F, 0xBB8FCE, 0x85C1E2, 0xF8B500, 0x6C5CE7
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function getAgentColor(name) {
|
|
21
|
+
let hash = 0;
|
|
22
|
+
for (let i = 0; i < name.length; i++) {
|
|
23
|
+
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
24
|
+
}
|
|
25
|
+
return agentColors[Math.abs(hash) % agentColors.length];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Initialize Three.js
|
|
29
|
+
function init() {
|
|
30
|
+
const container = document.getElementById('canvas-container');
|
|
31
|
+
|
|
32
|
+
scene = new THREE.Scene();
|
|
33
|
+
scene.background = new THREE.Color(0x667eea);
|
|
34
|
+
|
|
35
|
+
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
36
|
+
camera.position.set(0, 30, 40);
|
|
37
|
+
|
|
38
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
39
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
40
|
+
renderer.shadowMap.enabled = true;
|
|
41
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
42
|
+
container.appendChild(renderer.domElement);
|
|
43
|
+
|
|
44
|
+
controls = new OrbitControls(camera, renderer.domElement);
|
|
45
|
+
controls.enableDamping = true;
|
|
46
|
+
controls.dampingFactor = 0.05;
|
|
47
|
+
controls.maxPolarAngle = Math.PI / 2 - 0.1;
|
|
48
|
+
controls.minDistance = 20;
|
|
49
|
+
controls.maxDistance = 80;
|
|
50
|
+
|
|
51
|
+
// Lighting
|
|
52
|
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
53
|
+
scene.add(ambientLight);
|
|
54
|
+
|
|
55
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
56
|
+
dirLight.position.set(50, 100, 50);
|
|
57
|
+
dirLight.castShadow = true;
|
|
58
|
+
dirLight.shadow.camera.left = -50;
|
|
59
|
+
dirLight.shadow.camera.right = 50;
|
|
60
|
+
dirLight.shadow.camera.top = 50;
|
|
61
|
+
dirLight.shadow.camera.bottom = -50;
|
|
62
|
+
dirLight.shadow.mapSize.width = 2048;
|
|
63
|
+
dirLight.shadow.mapSize.height = 2048;
|
|
64
|
+
scene.add(dirLight);
|
|
65
|
+
|
|
66
|
+
// Ground
|
|
67
|
+
const groundGeo = new THREE.PlaneGeometry(200, 200);
|
|
68
|
+
const groundMat = new THREE.MeshStandardMaterial({
|
|
69
|
+
color: 0x7dd3c0,
|
|
70
|
+
roughness: 0.8
|
|
71
|
+
});
|
|
72
|
+
const ground = new THREE.Mesh(groundGeo, groundMat);
|
|
73
|
+
ground.rotation.x = -Math.PI / 2;
|
|
74
|
+
ground.receiveShadow = true;
|
|
75
|
+
scene.add(ground);
|
|
76
|
+
|
|
77
|
+
// Grid helper
|
|
78
|
+
const grid = new THREE.GridHelper(200, 50, 0xffffff, 0xffffff);
|
|
79
|
+
grid.material.opacity = 0.2;
|
|
80
|
+
grid.material.transparent = true;
|
|
81
|
+
scene.add(grid);
|
|
82
|
+
|
|
83
|
+
// Trees
|
|
84
|
+
createTrees();
|
|
85
|
+
|
|
86
|
+
window.addEventListener('resize', onWindowResize);
|
|
87
|
+
|
|
88
|
+
// Raycaster for house clicks
|
|
89
|
+
raycaster = new THREE.Raycaster();
|
|
90
|
+
mouse = new THREE.Vector2();
|
|
91
|
+
renderer.domElement.addEventListener('click', onHouseClick);
|
|
92
|
+
|
|
93
|
+
animate();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createTrees() {
|
|
97
|
+
for (let i = 0; i < 30; i++) {
|
|
98
|
+
const x = (Math.random() - 0.5) * 150;
|
|
99
|
+
const z = (Math.random() - 0.5) * 150;
|
|
100
|
+
|
|
101
|
+
// Don't place trees too close to center
|
|
102
|
+
if (Math.sqrt(x*x + z*z) < 30) continue;
|
|
103
|
+
|
|
104
|
+
const trunkGeo = new THREE.CylinderGeometry(0.5, 0.8, 3, 8);
|
|
105
|
+
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x8B4513 });
|
|
106
|
+
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
|
|
107
|
+
trunk.position.set(x, 1.5, z);
|
|
108
|
+
trunk.castShadow = true;
|
|
109
|
+
|
|
110
|
+
const leavesGeo = new THREE.ConeGeometry(3, 8, 8);
|
|
111
|
+
const leavesMat = new THREE.MeshStandardMaterial({ color: 0x228B22 });
|
|
112
|
+
const leaves = new THREE.Mesh(leavesGeo, leavesMat);
|
|
113
|
+
leaves.position.set(x, 6, z);
|
|
114
|
+
leaves.castShadow = true;
|
|
115
|
+
|
|
116
|
+
scene.add(trunk);
|
|
117
|
+
scene.add(leaves);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function createAgentHouse(name, position) {
|
|
122
|
+
const color = getAgentColor(name);
|
|
123
|
+
const group = new THREE.Group();
|
|
124
|
+
group.position.copy(position);
|
|
125
|
+
|
|
126
|
+
// House base
|
|
127
|
+
const baseGeo = new THREE.BoxGeometry(6, 4, 6);
|
|
128
|
+
const baseMat = new THREE.MeshStandardMaterial({ color: color });
|
|
129
|
+
const base = new THREE.Mesh(baseGeo, baseMat);
|
|
130
|
+
base.position.y = 2;
|
|
131
|
+
base.castShadow = true;
|
|
132
|
+
base.receiveShadow = true;
|
|
133
|
+
group.add(base);
|
|
134
|
+
|
|
135
|
+
// Roof
|
|
136
|
+
const roofGeo = new THREE.ConeGeometry(5, 3, 4);
|
|
137
|
+
const roofMat = new THREE.MeshStandardMaterial({ color: 0x8B4513 });
|
|
138
|
+
const roof = new THREE.Mesh(roofGeo, roofMat);
|
|
139
|
+
roof.position.y = 5.5;
|
|
140
|
+
roof.rotation.y = Math.PI / 4;
|
|
141
|
+
roof.castShadow = true;
|
|
142
|
+
group.add(roof);
|
|
143
|
+
|
|
144
|
+
// Door
|
|
145
|
+
const doorGeo = new THREE.BoxGeometry(1.5, 2.5, 0.2);
|
|
146
|
+
const doorMat = new THREE.MeshStandardMaterial({ color: 0x4a3c28 });
|
|
147
|
+
const door = new THREE.Mesh(doorGeo, doorMat);
|
|
148
|
+
door.position.set(0, 1.25, 3.1);
|
|
149
|
+
group.add(door);
|
|
150
|
+
|
|
151
|
+
// Windows
|
|
152
|
+
const windowGeo = new THREE.BoxGeometry(1.2, 1.2, 0.2);
|
|
153
|
+
const windowMat = new THREE.MeshStandardMaterial({
|
|
154
|
+
color: 0xFFFF99,
|
|
155
|
+
emissive: 0xFFFF99,
|
|
156
|
+
emissiveIntensity: 0.3
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const window1 = new THREE.Mesh(windowGeo, windowMat);
|
|
160
|
+
window1.position.set(-1.8, 2.5, 3.1);
|
|
161
|
+
group.add(window1);
|
|
162
|
+
|
|
163
|
+
const window2 = new THREE.Mesh(windowGeo, windowMat);
|
|
164
|
+
window2.position.set(1.8, 2.5, 3.1);
|
|
165
|
+
group.add(window2);
|
|
166
|
+
|
|
167
|
+
// Name label sprite
|
|
168
|
+
const canvas = document.createElement('canvas');
|
|
169
|
+
const context = canvas.getContext('2d');
|
|
170
|
+
// High DPI canvas for crisp text
|
|
171
|
+
const scale = 2;
|
|
172
|
+
canvas.width = 512;
|
|
173
|
+
canvas.height = 128;
|
|
174
|
+
context.scale(scale, scale);
|
|
175
|
+
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
176
|
+
context.roundRect(0, 0, 256, 64, 16);
|
|
177
|
+
context.fill();
|
|
178
|
+
context.font = 'bold 24px Arial';
|
|
179
|
+
context.fillStyle = 'white';
|
|
180
|
+
context.textAlign = 'center';
|
|
181
|
+
context.textBaseline = 'middle';
|
|
182
|
+
context.fillText(name, 128, 32);
|
|
183
|
+
|
|
184
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
185
|
+
texture.minFilter = THREE.LinearFilter;
|
|
186
|
+
texture.magFilter = THREE.LinearFilter;
|
|
187
|
+
const spriteMat = new THREE.SpriteMaterial({ map: texture });
|
|
188
|
+
const sprite = new THREE.Sprite(spriteMat);
|
|
189
|
+
sprite.position.set(0, 8, 0);
|
|
190
|
+
sprite.scale.set(8, 2, 1);
|
|
191
|
+
group.add(sprite);
|
|
192
|
+
|
|
193
|
+
// Path to house
|
|
194
|
+
const pathGeo = new THREE.PlaneGeometry(2, 8);
|
|
195
|
+
const pathMat = new THREE.MeshStandardMaterial({ color: 0xD2B48C });
|
|
196
|
+
const path = new THREE.Mesh(pathGeo, pathMat);
|
|
197
|
+
path.rotation.x = -Math.PI / 2;
|
|
198
|
+
path.position.set(0, 0.02, 7);
|
|
199
|
+
group.add(path);
|
|
200
|
+
|
|
201
|
+
scene.add(group);
|
|
202
|
+
agentMeshes.set(name, group);
|
|
203
|
+
|
|
204
|
+
return group;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createMessageParticle(fromPos, toPos) {
|
|
208
|
+
const particleGeo = new THREE.SphereGeometry(0.3, 8, 8);
|
|
209
|
+
const particleMat = new THREE.MeshStandardMaterial({
|
|
210
|
+
color: 0xFFD700,
|
|
211
|
+
emissive: 0xFFD700,
|
|
212
|
+
emissiveIntensity: 0.5
|
|
213
|
+
});
|
|
214
|
+
const particle = new THREE.Mesh(particleGeo, particleMat);
|
|
215
|
+
|
|
216
|
+
particle.position.copy(fromPos);
|
|
217
|
+
particle.position.y += 8;
|
|
218
|
+
|
|
219
|
+
scene.add(particle);
|
|
220
|
+
|
|
221
|
+
// Animate particle
|
|
222
|
+
const startTime = Date.now();
|
|
223
|
+
const duration = 2000;
|
|
224
|
+
|
|
225
|
+
function animateParticle() {
|
|
226
|
+
const elapsed = Date.now() - startTime;
|
|
227
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
228
|
+
|
|
229
|
+
particle.position.lerpVectors(
|
|
230
|
+
new THREE.Vector3(fromPos.x, fromPos.y + 8, fromPos.z),
|
|
231
|
+
new THREE.Vector3(toPos.x, toPos.y + 8, toPos.z),
|
|
232
|
+
progress
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Add arc
|
|
236
|
+
particle.position.y += Math.sin(progress * Math.PI) * 3;
|
|
237
|
+
|
|
238
|
+
if (progress < 1) {
|
|
239
|
+
requestAnimationFrame(animateParticle);
|
|
240
|
+
} else {
|
|
241
|
+
scene.remove(particle);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
animateParticle();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createConnectionLine(fromPos, toPos, isUnread) {
|
|
249
|
+
const material = new THREE.LineBasicMaterial({
|
|
250
|
+
color: isUnread ? 0xff6b6b : 0x4CAF50,
|
|
251
|
+
opacity: isUnread ? 0.8 : 0.3,
|
|
252
|
+
transparent: true,
|
|
253
|
+
linewidth: isUnread ? 3 : 1
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const points = [
|
|
257
|
+
new THREE.Vector3(fromPos.x, fromPos.y + 5, fromPos.z),
|
|
258
|
+
new THREE.Vector3(toPos.x, toPos.y + 5, toPos.z)
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
|
262
|
+
const line = new THREE.Line(geometry, material);
|
|
263
|
+
|
|
264
|
+
scene.add(line);
|
|
265
|
+
connectionLines.push(line);
|
|
266
|
+
|
|
267
|
+
// Send particle
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
createMessageParticle(fromPos, toPos);
|
|
270
|
+
}, 100);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clearConnections() {
|
|
274
|
+
connectionLines.forEach(line => scene.remove(line));
|
|
275
|
+
connectionLines = [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function updateVillage() {
|
|
279
|
+
clearConnections();
|
|
280
|
+
|
|
281
|
+
// Use recipients (from coworkers.db) as the authoritative list of agents
|
|
282
|
+
// This ensures we only show houses for registered coworkers
|
|
283
|
+
const allAgents = new Set([config.user.toLowerCase(), ...recipients.map(r => r.toLowerCase())]);
|
|
284
|
+
|
|
285
|
+
// Also add message participants that might not be in coworker db yet
|
|
286
|
+
messages.forEach(m => {
|
|
287
|
+
allAgents.add(m.sender.toLowerCase());
|
|
288
|
+
allAgents.add(m.recipient.toLowerCase());
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Arrange agents in a circle
|
|
292
|
+
const agents = Array.from(allAgents);
|
|
293
|
+
const radius = 25;
|
|
294
|
+
|
|
295
|
+
agents.forEach((agent, index) => {
|
|
296
|
+
const angle = (index / agents.length) * Math.PI * 2;
|
|
297
|
+
const x = Math.cos(angle) * radius;
|
|
298
|
+
const z = Math.sin(angle) * radius;
|
|
299
|
+
const position = new THREE.Vector3(x, 0, z);
|
|
300
|
+
|
|
301
|
+
if (!agentMeshes.has(agent)) {
|
|
302
|
+
createAgentHouse(agent, position);
|
|
303
|
+
} else {
|
|
304
|
+
// Update position if needed
|
|
305
|
+
const house = agentMeshes.get(agent);
|
|
306
|
+
house.position.copy(position);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Create connections based on messages
|
|
311
|
+
messages.forEach(msg => {
|
|
312
|
+
const fromHouse = agentMeshes.get(msg.sender.toLowerCase());
|
|
313
|
+
const toHouse = agentMeshes.get(msg.recipient.toLowerCase());
|
|
314
|
+
|
|
315
|
+
if (fromHouse && toHouse) {
|
|
316
|
+
createConnectionLine(
|
|
317
|
+
fromHouse.position,
|
|
318
|
+
toHouse.position,
|
|
319
|
+
!msg.read && msg.recipient.toLowerCase() === config.user.toLowerCase()
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Update house labels with unread indicators
|
|
325
|
+
updateHouseLabels();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function animate() {
|
|
329
|
+
requestAnimationFrame(animate);
|
|
330
|
+
controls.update();
|
|
331
|
+
renderer.render(scene, camera);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function onWindowResize() {
|
|
335
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
336
|
+
camera.updateProjectionMatrix();
|
|
337
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// API and UI Functions
|
|
341
|
+
async function loadData() {
|
|
342
|
+
try {
|
|
343
|
+
const [configRes, messagesRes, coworkersRes, allMessagesRes] = await Promise.all([
|
|
344
|
+
fetch('/api/config'),
|
|
345
|
+
fetch('/api/messages'),
|
|
346
|
+
fetch('/api/coworkers'),
|
|
347
|
+
fetch('/api/messages/all')
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
config = await configRes.json();
|
|
351
|
+
messages = await messagesRes.json();
|
|
352
|
+
recipients = await coworkersRes.json(); // Use coworkers endpoint
|
|
353
|
+
allMessages = await allMessagesRes.json();
|
|
354
|
+
|
|
355
|
+
updateUI();
|
|
356
|
+
updateVillage();
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.error('Error loading data:', err);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Panel toggle functions
|
|
363
|
+
window.toggleSendPanel = function() {
|
|
364
|
+
const panel = document.getElementById('send-panel');
|
|
365
|
+
const btn = document.getElementById('collapse-btn');
|
|
366
|
+
panel.classList.toggle('collapsed');
|
|
367
|
+
btn.textContent = panel.classList.contains('collapsed') ? '+' : '−';
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
window.toggleMessagesPanel = function() {
|
|
371
|
+
const panel = document.getElementById('messages-panel');
|
|
372
|
+
panel.classList.toggle('open');
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
function updateUI() {
|
|
376
|
+
const unread = messages.filter(m => !m.read && m.recipient.toLowerCase() === config.user.toLowerCase()).length;
|
|
377
|
+
|
|
378
|
+
// Update messages button - change icon and show badge when unread
|
|
379
|
+
const msgBtn = document.getElementById('toggle-messages-btn');
|
|
380
|
+
const badge = document.getElementById('unread-badge');
|
|
381
|
+
if (unread > 0) {
|
|
382
|
+
msgBtn.innerHTML = `🔔 Messages <span class="badge" id="unread-badge">${unread}</span>`;
|
|
383
|
+
} else {
|
|
384
|
+
msgBtn.innerHTML = `📨 Messages <span class="badge" id="unread-badge" style="display: none;">0</span>`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Update recipient select (send panel) - only from coworkers.db
|
|
388
|
+
const select = document.getElementById('recipient-select');
|
|
389
|
+
const currentVal = select.value;
|
|
390
|
+
select.innerHTML = '<option value="">To: Select agent...</option>' +
|
|
391
|
+
recipients.sort().map(r =>
|
|
392
|
+
`<option value="${r}" ${r === currentVal ? 'selected' : ''}>${r}</option>`
|
|
393
|
+
).join('');
|
|
394
|
+
|
|
395
|
+
// Update messages list (slide-out panel)
|
|
396
|
+
const messagesDiv = document.getElementById('messages-container');
|
|
397
|
+
if (messages.length === 0) {
|
|
398
|
+
messagesDiv.innerHTML = `
|
|
399
|
+
<div class="empty-state">
|
|
400
|
+
<div style="font-size: 2rem; margin-bottom: 8px;">ðŸ“</div>
|
|
401
|
+
<p>No messages yet</p>
|
|
402
|
+
</div>
|
|
403
|
+
`;
|
|
404
|
+
} else {
|
|
405
|
+
messagesDiv.innerHTML = messages.slice(0, 20).map(msg => `
|
|
406
|
+
<div class="message-card ${msg.read ? '' : 'unread'}" data-id="${msg.id}" data-sender="${msg.sender}" data-recipient="${msg.recipient}">
|
|
407
|
+
<div class="message-header">
|
|
408
|
+
<span class="message-sender">${msg.sender} → ${msg.recipient}</span>
|
|
409
|
+
<span class="message-time">${new Date(msg.timestamp).toLocaleString()}</span>
|
|
410
|
+
</div>
|
|
411
|
+
<div class="message-text">${msg.message}</div>
|
|
412
|
+
</div>
|
|
413
|
+
`).join('');
|
|
414
|
+
|
|
415
|
+
// Add click handlers for all messages (clicking marks as read and sets recipient for reply)
|
|
416
|
+
messagesDiv.querySelectorAll('.message-card').forEach(el => {
|
|
417
|
+
el.addEventListener('click', () => {
|
|
418
|
+
const msgId = el.dataset.id;
|
|
419
|
+
const sender = el.dataset.sender;
|
|
420
|
+
const recipient = el.dataset.recipient;
|
|
421
|
+
|
|
422
|
+
// Determine who to reply to
|
|
423
|
+
// If I received the message, reply to sender
|
|
424
|
+
// If I sent the message, reply to the original recipient
|
|
425
|
+
const myName = config.user.toLowerCase();
|
|
426
|
+
const replyTo = recipient.toLowerCase() === myName ? sender : recipient;
|
|
427
|
+
|
|
428
|
+
// Set the recipient select
|
|
429
|
+
const select = document.getElementById('recipient-select');
|
|
430
|
+
if (select) {
|
|
431
|
+
select.value = replyTo;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Mark as read
|
|
435
|
+
markAsRead(msgId);
|
|
436
|
+
|
|
437
|
+
// Focus the message input for typing
|
|
438
|
+
const messageInput = document.getElementById('message-input');
|
|
439
|
+
if (messageInput) {
|
|
440
|
+
messageInput.focus();
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Update house dialog if it's open
|
|
447
|
+
if (document.getElementById('house-dialog').classList.contains('active')) {
|
|
448
|
+
updateHouseDialogContent();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function markAsRead(id) {
|
|
453
|
+
try {
|
|
454
|
+
await fetch(`/api/messages/${id}/read`, { method: 'POST' });
|
|
455
|
+
loadData();
|
|
456
|
+
} catch (err) {
|
|
457
|
+
console.error('Error marking as read:', err);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function sendMessage() {
|
|
462
|
+
const to = document.getElementById('recipient-select').value;
|
|
463
|
+
const message = document.getElementById('message-input').value.trim();
|
|
464
|
+
|
|
465
|
+
if (!to || !message) {
|
|
466
|
+
alert('Please select a coworker and enter a message');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
const response = await fetch('/api/send', {
|
|
472
|
+
method: 'POST',
|
|
473
|
+
headers: { 'Content-Type': 'application/json' },
|
|
474
|
+
body: JSON.stringify({ to, from: config.user, message })
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (response.ok) {
|
|
478
|
+
// Clear input
|
|
479
|
+
document.getElementById('message-input').value = '';
|
|
480
|
+
|
|
481
|
+
// Show toast
|
|
482
|
+
const toast = document.getElementById('toast');
|
|
483
|
+
toast.classList.add('show');
|
|
484
|
+
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
485
|
+
|
|
486
|
+
// Reload data
|
|
487
|
+
loadData();
|
|
488
|
+
} else {
|
|
489
|
+
alert('Failed to send message');
|
|
490
|
+
}
|
|
491
|
+
} catch (err) {
|
|
492
|
+
console.error('Error sending:', err);
|
|
493
|
+
alert('Error sending message');
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// House click handler
|
|
498
|
+
function onHouseClick(event) {
|
|
499
|
+
// Calculate mouse position
|
|
500
|
+
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
|
501
|
+
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
|
502
|
+
|
|
503
|
+
raycaster.setFromCamera(mouse, camera);
|
|
504
|
+
|
|
505
|
+
// Get all house meshes
|
|
506
|
+
const houseMeshes = [];
|
|
507
|
+
agentMeshes.forEach((group, name) => {
|
|
508
|
+
group.children.forEach(child => {
|
|
509
|
+
if (child.isMesh && !child.userData.isBubble && !child.userData.isCup) {
|
|
510
|
+
child.userData.agentName = name;
|
|
511
|
+
houseMeshes.push(child);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const intersects = raycaster.intersectObjects(houseMeshes);
|
|
517
|
+
|
|
518
|
+
if (intersects.length > 0) {
|
|
519
|
+
const agentName = intersects[0].object.userData.agentName;
|
|
520
|
+
if (agentName) {
|
|
521
|
+
showHouseDialog(agentName);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Global variable to track current agent for house dialog
|
|
527
|
+
let currentHouseAgent = null;
|
|
528
|
+
let currentTab = 'received';
|
|
529
|
+
|
|
530
|
+
// Show dialog with messages for a specific agent
|
|
531
|
+
async function showHouseDialog(agentName) {
|
|
532
|
+
currentHouseAgent = agentName.toLowerCase();
|
|
533
|
+
currentTab = 'received'; // Default to received tab
|
|
534
|
+
|
|
535
|
+
const dialog = document.getElementById('house-dialog');
|
|
536
|
+
const title = document.getElementById('house-dialog-title');
|
|
537
|
+
|
|
538
|
+
// Capitalize first letter
|
|
539
|
+
const displayName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
|
|
540
|
+
title.textContent = `${displayName}'s Messages`;
|
|
541
|
+
|
|
542
|
+
// Update tab labels
|
|
543
|
+
document.getElementById('tab-received').innerHTML =
|
|
544
|
+
`📥 Received by ${displayName} <span id="received-count" class="tab-badge"></span>`;
|
|
545
|
+
document.getElementById('tab-sent').innerHTML =
|
|
546
|
+
`📤 Sent by ${displayName} <span id="sent-count" class="tab-badge"></span>`;
|
|
547
|
+
|
|
548
|
+
// Load all messages (both sent and received) for this dialog
|
|
549
|
+
try {
|
|
550
|
+
const response = await fetch('/api/messages/all');
|
|
551
|
+
allMessages = await response.json();
|
|
552
|
+
} catch (err) {
|
|
553
|
+
console.error('Error loading all messages:', err);
|
|
554
|
+
allMessages = [];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Switch to received tab by default
|
|
558
|
+
switchTab('received');
|
|
559
|
+
|
|
560
|
+
dialog.classList.add('active');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Tab switching function
|
|
564
|
+
window.switchTab = function(tab) {
|
|
565
|
+
currentTab = tab;
|
|
566
|
+
|
|
567
|
+
// Update tab buttons
|
|
568
|
+
document.getElementById('tab-received').classList.toggle('active', tab === 'received');
|
|
569
|
+
document.getElementById('tab-sent').classList.toggle('active', tab === 'sent');
|
|
570
|
+
|
|
571
|
+
// Update content
|
|
572
|
+
updateHouseDialogContent();
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
function updateHouseDialogContent() {
|
|
576
|
+
const content = document.getElementById('house-dialog-content');
|
|
577
|
+
|
|
578
|
+
if (!currentHouseAgent) return;
|
|
579
|
+
|
|
580
|
+
// Filter messages based on current tab - FROM THE AGENT'S PERSPECTIVE
|
|
581
|
+
let filteredMessages;
|
|
582
|
+
if (currentTab === 'received') {
|
|
583
|
+
// Messages RECEIVED BY the agent (sent TO the agent)
|
|
584
|
+
filteredMessages = allMessages.filter(m =>
|
|
585
|
+
m.recipient.toLowerCase() === currentHouseAgent
|
|
586
|
+
);
|
|
587
|
+
} else {
|
|
588
|
+
// Messages SENT BY the agent
|
|
589
|
+
filteredMessages = allMessages.filter(m =>
|
|
590
|
+
m.sender.toLowerCase() === currentHouseAgent
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Update count badges
|
|
595
|
+
const receivedCount = allMessages.filter(m =>
|
|
596
|
+
m.recipient.toLowerCase() === currentHouseAgent
|
|
597
|
+
).length;
|
|
598
|
+
|
|
599
|
+
const sentCount = allMessages.filter(m =>
|
|
600
|
+
m.sender.toLowerCase() === currentHouseAgent
|
|
601
|
+
).length;
|
|
602
|
+
|
|
603
|
+
const receivedBadge = document.getElementById('received-count');
|
|
604
|
+
const sentBadge = document.getElementById('sent-count');
|
|
605
|
+
|
|
606
|
+
receivedBadge.textContent = receivedCount > 0 ? receivedCount : '';
|
|
607
|
+
sentBadge.textContent = sentCount > 0 ? sentCount : '';
|
|
608
|
+
|
|
609
|
+
// Render messages
|
|
610
|
+
if (filteredMessages.length === 0) {
|
|
611
|
+
content.innerHTML = `
|
|
612
|
+
<div class="empty-state">
|
|
613
|
+
<div style="font-size: 2rem; margin-bottom: 8px;">ðŸ“</div>
|
|
614
|
+
<p>No ${currentTab} messages</p>
|
|
615
|
+
</div>
|
|
616
|
+
`;
|
|
617
|
+
} else {
|
|
618
|
+
content.innerHTML = filteredMessages.map(msg => `
|
|
619
|
+
<div class="message-card ${msg.read ? '' : 'unread'}" style="margin-bottom: 12px;">
|
|
620
|
+
<div class="message-header">
|
|
621
|
+
<span class="message-sender">${msg.sender} → ${msg.recipient}</span>
|
|
622
|
+
<span class="message-time">${new Date(msg.timestamp).toLocaleString()}</span>
|
|
623
|
+
</div>
|
|
624
|
+
<div class="message-text">${msg.message}</div>
|
|
625
|
+
</div>
|
|
626
|
+
`).join('');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
window.closeHouseDialog = function() {
|
|
631
|
+
document.getElementById('house-dialog').classList.remove('active');
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// Update house labels to show unread indicators
|
|
635
|
+
function updateHouseLabels() {
|
|
636
|
+
agentMeshes.forEach((group, name) => {
|
|
637
|
+
// Check for unread messages SENT TO this agent (messages they haven't read)
|
|
638
|
+
const unreadCount = allMessages.filter(m =>
|
|
639
|
+
m.recipient.toLowerCase() === name.toLowerCase() &&
|
|
640
|
+
m.sender.toLowerCase() === config.user.toLowerCase() &&
|
|
641
|
+
!m.read
|
|
642
|
+
).length;
|
|
643
|
+
|
|
644
|
+
// Also check if this agent has sent unread messages TO user
|
|
645
|
+
const unreadFromAgent = allMessages.filter(m =>
|
|
646
|
+
m.sender.toLowerCase() === name.toLowerCase() &&
|
|
647
|
+
m.recipient.toLowerCase() === config.user.toLowerCase() &&
|
|
648
|
+
!m.read
|
|
649
|
+
).length;
|
|
650
|
+
|
|
651
|
+
// Find the sprite label
|
|
652
|
+
const sprite = group.children.find(c => c.isSprite);
|
|
653
|
+
if (sprite) {
|
|
654
|
+
// Update the canvas texture - high DPI for crisp text
|
|
655
|
+
const canvas = document.createElement('canvas');
|
|
656
|
+
const context = canvas.getContext('2d');
|
|
657
|
+
const scale = 2;
|
|
658
|
+
canvas.width = 700;
|
|
659
|
+
canvas.height = 128;
|
|
660
|
+
context.scale(scale, scale);
|
|
661
|
+
|
|
662
|
+
// Background - change color if there are unread messages
|
|
663
|
+
if (unreadFromAgent > 0) {
|
|
664
|
+
// Red background for unread messages from agent
|
|
665
|
+
context.fillStyle = 'rgba(220, 53, 69, 0.9)';
|
|
666
|
+
} else if (unreadCount > 0) {
|
|
667
|
+
// Blue background for messages sent but not read
|
|
668
|
+
context.fillStyle = 'rgba(0, 123, 255, 0.9)';
|
|
669
|
+
} else {
|
|
670
|
+
// Default black background
|
|
671
|
+
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
672
|
+
}
|
|
673
|
+
context.roundRect(0, 0, 350, 64, 16);
|
|
674
|
+
context.fill();
|
|
675
|
+
|
|
676
|
+
// Name
|
|
677
|
+
context.font = 'bold 24px Arial';
|
|
678
|
+
context.fillStyle = 'white';
|
|
679
|
+
context.textAlign = 'center';
|
|
680
|
+
context.textBaseline = 'middle';
|
|
681
|
+
|
|
682
|
+
if (unreadFromAgent > 0) {
|
|
683
|
+
// Show name with unread indicator from agent
|
|
684
|
+
context.fillText(`${name} 🔴 ${unreadFromAgent}`, 175, 32);
|
|
685
|
+
} else if (unreadCount > 0) {
|
|
686
|
+
// Show name with sent-but-unread count
|
|
687
|
+
context.fillText(`${name} 📤 ${unreadCount}`, 175, 32);
|
|
688
|
+
} else {
|
|
689
|
+
context.fillText(name, 175, 32);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
693
|
+
texture.minFilter = THREE.LinearFilter;
|
|
694
|
+
texture.magFilter = THREE.LinearFilter;
|
|
695
|
+
sprite.material.map = texture;
|
|
696
|
+
sprite.material.needsUpdate = true;
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Event listeners
|
|
702
|
+
document.getElementById('send-btn').addEventListener('click', sendMessage);
|
|
703
|
+
|
|
704
|
+
// Initialize
|
|
705
|
+
init();
|
|
706
|
+
loadData();
|
|
707
|
+
setInterval(loadData, 5000);
|