@mooncompany/uplink-chat 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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,452 @@
1
+ // ============================================
2
+ // DASHBOARD MODULE
3
+ // Usage analytics, message stats, cost tracking
4
+ // ============================================
5
+
6
+ import { UplinkCore } from './core.js';
7
+
8
+ // State
9
+ let dashboardVisible = false;
10
+ let dashboardEl = null;
11
+ let stats = {
12
+ messages: [],
13
+ tokens: [],
14
+ sessions: 0
15
+ };
16
+
17
+ // Cost per 1K tokens (approximate for Claude)
18
+ const COST_PER_1K_INPUT = 0.003;
19
+ const COST_PER_1K_OUTPUT = 0.015;
20
+
21
+ function init() {
22
+ // Load saved stats
23
+ loadStats();
24
+
25
+ // Create dashboard panel
26
+ createDashboard();
27
+
28
+ // Add command
29
+ if (window.UplinkCommands) {
30
+ // Command will be added via commands.js
31
+ }
32
+
33
+ // Track messages
34
+ interceptMessages();
35
+
36
+ logger.debug('Dashboard: Initialized');
37
+ }
38
+
39
+ function createDashboard() {
40
+ dashboardEl = document.createElement('div');
41
+ dashboardEl.className = 'dashboard-overlay';
42
+ dashboardEl.innerHTML = `
43
+ <div class="dashboard-panel">
44
+ <div class="dashboard-header">
45
+ <h2>📊 Usage Dashboard</h2>
46
+ <button class="dashboard-close">&times;</button>
47
+ </div>
48
+ <div class="dashboard-content">
49
+ <div class="dashboard-section">
50
+ <h3>Today's Stats</h3>
51
+ <div class="stat-grid">
52
+ <div class="stat-card">
53
+ <span class="stat-value" id="statMessages">0</span>
54
+ <span class="stat-label">Messages</span>
55
+ </div>
56
+ <div class="stat-card">
57
+ <span class="stat-value" id="statTokens">0</span>
58
+ <span class="stat-label">Tokens</span>
59
+ </div>
60
+ <div class="stat-card">
61
+ <span class="stat-value" id="statCost">$0.00</span>
62
+ <span class="stat-label">Est. Cost</span>
63
+ </div>
64
+ <div class="stat-card">
65
+ <span class="stat-value" id="statAvgResponse">0s</span>
66
+ <span class="stat-label">Avg Response</span>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="dashboard-section">
72
+ <h3>Activity (Last 7 Days)</h3>
73
+ <div class="chart-container">
74
+ <canvas id="activityChart" width="400" height="150"></canvas>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="dashboard-section">
79
+ <h3>Peak Hours</h3>
80
+ <div class="chart-container">
81
+ <canvas id="hoursChart" width="400" height="100"></canvas>
82
+ </div>
83
+ </div>
84
+
85
+ <div class="dashboard-section">
86
+ <h3>All Time</h3>
87
+ <div class="stat-grid">
88
+ <div class="stat-card">
89
+ <span class="stat-value" id="statTotalMessages">0</span>
90
+ <span class="stat-label">Total Messages</span>
91
+ </div>
92
+ <div class="stat-card">
93
+ <span class="stat-value" id="statTotalTokens">0</span>
94
+ <span class="stat-label">Total Tokens</span>
95
+ </div>
96
+ <div class="stat-card">
97
+ <span class="stat-value" id="statTotalCost">$0.00</span>
98
+ <span class="stat-label">Total Cost</span>
99
+ </div>
100
+ <div class="stat-card">
101
+ <span class="stat-value" id="statSessions">0</span>
102
+ <span class="stat-label">Sessions</span>
103
+ </div>
104
+ </div>
105
+ </div>
106
+
107
+ <div class="dashboard-actions">
108
+ <button id="exportStats" class="dashboard-btn">Export Data</button>
109
+ <button id="resetStats" class="dashboard-btn danger">Reset Stats</button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ `;
114
+
115
+ document.body.appendChild(dashboardEl);
116
+
117
+ // Event listeners
118
+ dashboardEl.querySelector('.dashboard-close').addEventListener('click', hide);
119
+ dashboardEl.addEventListener('click', (e) => {
120
+ if (e.target === dashboardEl) hide();
121
+ });
122
+
123
+ document.getElementById('exportStats')?.addEventListener('click', exportStats);
124
+ document.getElementById('resetStats')?.addEventListener('click', resetStats);
125
+ }
126
+
127
+ function show() {
128
+ dashboardVisible = true;
129
+ dashboardEl.classList.add('visible');
130
+ updateDisplay();
131
+ drawCharts();
132
+ }
133
+
134
+ function hide() {
135
+ dashboardVisible = false;
136
+ dashboardEl.classList.remove('visible');
137
+ }
138
+
139
+ function toggle() {
140
+ if (dashboardVisible) hide();
141
+ else show();
142
+ }
143
+
144
+ function loadStats() {
145
+ try {
146
+ const saved = localStorage.getItem('uplink-stats');
147
+ if (saved) {
148
+ const parsed = JSON.parse(saved);
149
+ stats = { ...stats, ...parsed };
150
+ }
151
+ } catch (e) {
152
+ logger.warn('Dashboard: Failed to load stats', e);
153
+ }
154
+ }
155
+
156
+ function saveStats() {
157
+ try {
158
+ localStorage.setItem('uplink-stats', JSON.stringify(stats));
159
+ } catch (e) {
160
+ logger.warn('Dashboard: Failed to save stats', e);
161
+ }
162
+ }
163
+
164
+ function interceptMessages() {
165
+ // Use shared fetch utilities if available
166
+ if (window.UplinkFetch) {
167
+ window.UplinkFetch.registerHook('afterResponse',
168
+ async (url, options, response, duration) => {
169
+ // Track chat API calls
170
+ if (url === '/api/chat') {
171
+ trackMessage('user', duration);
172
+
173
+ // Try to get token usage from response
174
+ try {
175
+ const data = await response.json();
176
+ if (data.usage) {
177
+ trackTokens(data.usage.prompt_tokens || 0, data.usage.completion_tokens || 0);
178
+ }
179
+ } catch (e) {
180
+ logger.warn('Dashboard: Failed to parse chat response for token tracking', e);
181
+ }
182
+ }
183
+ }
184
+ );
185
+ }
186
+
187
+ // Also listen for streaming token updates from developer panel
188
+ const originalUpdateTokens = window.UplinkDeveloper?.updateTokens;
189
+ if (originalUpdateTokens) {
190
+ window.UplinkDeveloper.updateTokens = function(usage) {
191
+ originalUpdateTokens.call(window.UplinkDeveloper, usage);
192
+ trackTokens(
193
+ usage.prompt_tokens || usage.promptTokens || 0,
194
+ usage.completion_tokens || usage.completionTokens || 0
195
+ );
196
+ };
197
+ }
198
+ }
199
+
200
+ function trackMessage(type, durationMs = 0) {
201
+ const now = new Date();
202
+ const today = now.toISOString().split('T')[0];
203
+ const hour = now.getHours();
204
+
205
+ stats.messages.push({
206
+ date: today,
207
+ hour,
208
+ type,
209
+ duration: durationMs,
210
+ timestamp: now.getTime()
211
+ });
212
+
213
+ // Keep only last 30 days of data
214
+ const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
215
+ stats.messages = stats.messages.filter(m => m.timestamp > thirtyDaysAgo);
216
+
217
+ saveStats();
218
+ }
219
+
220
+ function trackTokens(input, output) {
221
+ const now = new Date();
222
+ const today = now.toISOString().split('T')[0];
223
+
224
+ stats.tokens.push({
225
+ date: today,
226
+ input,
227
+ output,
228
+ timestamp: now.getTime()
229
+ });
230
+
231
+ // Keep only last 30 days
232
+ const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
233
+ stats.tokens = stats.tokens.filter(t => t.timestamp > thirtyDaysAgo);
234
+
235
+ saveStats();
236
+
237
+ if (dashboardVisible) {
238
+ updateDisplay();
239
+ }
240
+ }
241
+
242
+ function updateDisplay() {
243
+ const today = new Date().toISOString().split('T')[0];
244
+
245
+ // Today's messages
246
+ const todayMessages = stats.messages.filter(m => m.date === today);
247
+ document.getElementById('statMessages').textContent = todayMessages.length;
248
+
249
+ // Today's tokens
250
+ const todayTokens = stats.tokens.filter(t => t.date === today);
251
+ const todayInputTokens = todayTokens.reduce((sum, t) => sum + t.input, 0);
252
+ const todayOutputTokens = todayTokens.reduce((sum, t) => sum + t.output, 0);
253
+ const todayTotalTokens = todayInputTokens + todayOutputTokens;
254
+ document.getElementById('statTokens').textContent = formatNumber(todayTotalTokens);
255
+
256
+ // Today's cost
257
+ const todayCost = (todayInputTokens / 1000 * COST_PER_1K_INPUT) +
258
+ (todayOutputTokens / 1000 * COST_PER_1K_OUTPUT);
259
+ document.getElementById('statCost').textContent = '$' + todayCost.toFixed(2);
260
+
261
+ // Average response time
262
+ const responseTimes = todayMessages.filter(m => m.duration > 0).map(m => m.duration);
263
+ const avgResponse = responseTimes.length > 0
264
+ ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length / 1000
265
+ : 0;
266
+ document.getElementById('statAvgResponse').textContent = avgResponse.toFixed(1) + 's';
267
+
268
+ // All time stats
269
+ document.getElementById('statTotalMessages').textContent = formatNumber(stats.messages.length);
270
+
271
+ const totalInputTokens = stats.tokens.reduce((sum, t) => sum + t.input, 0);
272
+ const totalOutputTokens = stats.tokens.reduce((sum, t) => sum + t.output, 0);
273
+ document.getElementById('statTotalTokens').textContent = formatNumber(totalInputTokens + totalOutputTokens);
274
+
275
+ const totalCost = (totalInputTokens / 1000 * COST_PER_1K_INPUT) +
276
+ (totalOutputTokens / 1000 * COST_PER_1K_OUTPUT);
277
+ document.getElementById('statTotalCost').textContent = '$' + totalCost.toFixed(2);
278
+
279
+ // Sessions (unique days with activity)
280
+ const uniqueDays = new Set(stats.messages.map(m => m.date));
281
+ document.getElementById('statSessions').textContent = uniqueDays.size;
282
+ }
283
+
284
+ function drawCharts() {
285
+ drawActivityChart();
286
+ drawHoursChart();
287
+ }
288
+
289
+ function drawActivityChart() {
290
+ const canvas = document.getElementById('activityChart');
291
+ if (!canvas) return;
292
+
293
+ const ctx = canvas.getContext('2d');
294
+ const width = canvas.width;
295
+ const height = canvas.height;
296
+
297
+ ctx.clearRect(0, 0, width, height);
298
+
299
+ // Get last 7 days
300
+ const days = [];
301
+ for (let i = 6; i >= 0; i--) {
302
+ const date = new Date();
303
+ date.setDate(date.getDate() - i);
304
+ days.push(date.toISOString().split('T')[0]);
305
+ }
306
+
307
+ // Count messages per day
308
+ const counts = days.map(day =>
309
+ stats.messages.filter(m => m.date === day).length
310
+ );
311
+
312
+ const maxCount = Math.max(...counts, 1);
313
+ const barWidth = (width - 60) / 7;
314
+ const barGap = 8;
315
+
316
+ // Draw bars
317
+ ctx.fillStyle = 'rgba(0, 240, 255, 0.6)';
318
+ counts.forEach((count, i) => {
319
+ const barHeight = (count / maxCount) * (height - 40);
320
+ const x = 30 + i * barWidth + barGap / 2;
321
+ const y = height - 20 - barHeight;
322
+
323
+ ctx.beginPath();
324
+ ctx.roundRect(x, y, barWidth - barGap, barHeight, 4);
325
+ ctx.fill();
326
+ });
327
+
328
+ // Draw labels
329
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
330
+ ctx.font = '10px system-ui';
331
+ ctx.textAlign = 'center';
332
+ days.forEach((day, i) => {
333
+ const label = new Date(day).toLocaleDateString('en', { weekday: 'short' });
334
+ const x = 30 + i * barWidth + barWidth / 2;
335
+ ctx.fillText(label, x, height - 5);
336
+ });
337
+
338
+ // Draw count on top of bars
339
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
340
+ counts.forEach((count, i) => {
341
+ if (count > 0) {
342
+ const barHeight = (count / maxCount) * (height - 40);
343
+ const x = 30 + i * barWidth + barWidth / 2;
344
+ const y = height - 25 - barHeight;
345
+ ctx.fillText(count.toString(), x, y);
346
+ }
347
+ });
348
+ }
349
+
350
+ function drawHoursChart() {
351
+ const canvas = document.getElementById('hoursChart');
352
+ if (!canvas) return;
353
+
354
+ const ctx = canvas.getContext('2d');
355
+ const width = canvas.width;
356
+ const height = canvas.height;
357
+
358
+ ctx.clearRect(0, 0, width, height);
359
+
360
+ // Count messages per hour
361
+ const hourCounts = new Array(24).fill(0);
362
+ stats.messages.forEach(m => {
363
+ if (m.hour !== undefined) {
364
+ hourCounts[m.hour]++;
365
+ }
366
+ });
367
+
368
+ const maxCount = Math.max(...hourCounts, 1);
369
+ const barWidth = (width - 20) / 24;
370
+
371
+ // Draw bars
372
+ hourCounts.forEach((count, hour) => {
373
+ const barHeight = (count / maxCount) * (height - 25);
374
+ const x = 10 + hour * barWidth;
375
+ const y = height - 15 - barHeight;
376
+
377
+ // Color based on time of day
378
+ if (hour >= 6 && hour < 12) {
379
+ ctx.fillStyle = 'rgba(255, 200, 100, 0.6)'; // Morning
380
+ } else if (hour >= 12 && hour < 18) {
381
+ ctx.fillStyle = 'rgba(0, 240, 255, 0.6)'; // Afternoon
382
+ } else if (hour >= 18 && hour < 22) {
383
+ ctx.fillStyle = 'rgba(180, 100, 255, 0.6)'; // Evening
384
+ } else {
385
+ ctx.fillStyle = 'rgba(100, 100, 150, 0.6)'; // Night
386
+ }
387
+
388
+ ctx.fillRect(x, y, barWidth - 1, barHeight);
389
+ });
390
+
391
+ // Draw hour labels (every 6 hours)
392
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
393
+ ctx.font = '9px system-ui';
394
+ ctx.textAlign = 'center';
395
+ [0, 6, 12, 18].forEach(hour => {
396
+ const x = 10 + hour * barWidth + barWidth / 2;
397
+ const label = hour === 0 ? '12am' : hour === 12 ? '12pm' :
398
+ hour < 12 ? hour + 'am' : (hour - 12) + 'pm';
399
+ ctx.fillText(label, x, height - 3);
400
+ });
401
+ }
402
+
403
+ function exportStats() {
404
+ const data = {
405
+ exported: new Date().toISOString(),
406
+ messages: stats.messages,
407
+ tokens: stats.tokens,
408
+ summary: {
409
+ totalMessages: stats.messages.length,
410
+ totalInputTokens: stats.tokens.reduce((s, t) => s + t.input, 0),
411
+ totalOutputTokens: stats.tokens.reduce((s, t) => s + t.output, 0)
412
+ }
413
+ };
414
+
415
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
416
+ const url = URL.createObjectURL(blob);
417
+ const a = document.createElement('a');
418
+ a.href = url;
419
+ a.download = `uplink-stats-${new Date().toISOString().split('T')[0]}.json`;
420
+ a.click();
421
+ URL.revokeObjectURL(url);
422
+ }
423
+
424
+ function resetStats() {
425
+ if (!confirm('Reset all usage statistics? This cannot be undone.')) return;
426
+
427
+ stats = { messages: [], tokens: [], sessions: 0 };
428
+ saveStats();
429
+ updateDisplay();
430
+ drawCharts();
431
+ }
432
+
433
+ function formatNumber(n) {
434
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
435
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
436
+ return n.toString();
437
+ }
438
+
439
+ // Expose API
440
+ export const UplinkDashboard = {
441
+ show,
442
+ hide,
443
+ toggle,
444
+ trackMessage,
445
+ trackTokens
446
+ };
447
+
448
+ // Backward compat: assign to window
449
+ window.UplinkDashboard = UplinkDashboard;
450
+
451
+ // Register with core for coordinated initialization
452
+ UplinkCore.registerModule('dashboard', init);