@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// STORAGE MODULE
|
|
3
|
+
// History persistence with encryption support
|
|
4
|
+
// ============================================
|
|
5
|
+
|
|
6
|
+
import { UplinkCore } from './core.js';
|
|
7
|
+
import { UplinkEncryption } from './encryption.js';
|
|
8
|
+
|
|
9
|
+
const HISTORY_KEY = 'uplink-history';
|
|
10
|
+
const ENCRYPTED_KEY = 'uplink-history-encrypted';
|
|
11
|
+
const SETTINGS_KEY = 'uplink-settings';
|
|
12
|
+
const MAX_MESSAGES = 100;
|
|
13
|
+
|
|
14
|
+
// Queue for serializing concurrent saveMessage calls
|
|
15
|
+
let saveQueue = Promise.resolve();
|
|
16
|
+
|
|
17
|
+
// Get core state
|
|
18
|
+
function getCore() {
|
|
19
|
+
return UplinkCore.state || {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get encryption module
|
|
23
|
+
function getCrypto() {
|
|
24
|
+
return UplinkEncryption;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Save a message to history (queued to prevent concurrent write races)
|
|
28
|
+
async function saveMessage(msg) {
|
|
29
|
+
// Chain this save operation after any pending saves
|
|
30
|
+
saveQueue = saveQueue.then(async () => {
|
|
31
|
+
const core = getCore();
|
|
32
|
+
let history = await loadHistory();
|
|
33
|
+
|
|
34
|
+
// Don't store data URLs in history — they're too large for localStorage
|
|
35
|
+
// The server URL will replace it via updateLastImageUrl after upload completes
|
|
36
|
+
const safeMsg = { ...msg };
|
|
37
|
+
if (safeMsg.imageUrl && safeMsg.imageUrl.startsWith('data:')) {
|
|
38
|
+
safeMsg.imageUrl = '__pending_upload__';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
history.push({
|
|
42
|
+
...safeMsg,
|
|
43
|
+
timestamp: safeMsg.timestamp || Date.now()
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Limit history size
|
|
47
|
+
if (history.length > MAX_MESSAGES) {
|
|
48
|
+
history = history.slice(-MAX_MESSAGES);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await persistHistory(history);
|
|
52
|
+
}).catch(err => {
|
|
53
|
+
console.error('Storage: saveMessage error:', err);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return saveQueue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Load message history
|
|
60
|
+
async function loadHistory() {
|
|
61
|
+
const core = getCore();
|
|
62
|
+
const cryptoMod = getCrypto();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
let history = null;
|
|
66
|
+
if (core.encryptionEnabled && core.currentPassword && cryptoMod) {
|
|
67
|
+
const encrypted = localStorage.getItem(ENCRYPTED_KEY);
|
|
68
|
+
if (encrypted) {
|
|
69
|
+
history = await cryptoMod.decrypt(JSON.parse(encrypted), core.currentPassword);
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
const plain = localStorage.getItem(HISTORY_KEY);
|
|
73
|
+
if (plain) {
|
|
74
|
+
history = JSON.parse(plain);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (history) {
|
|
79
|
+
// Sanitize: strip any data URLs that may have leaked into storage
|
|
80
|
+
// These bloat localStorage and cause quota errors
|
|
81
|
+
let sanitized = false;
|
|
82
|
+
for (const msg of history) {
|
|
83
|
+
if (msg.imageUrl && msg.imageUrl.startsWith('data:')) {
|
|
84
|
+
msg.imageUrl = '__pending_upload__';
|
|
85
|
+
sanitized = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (sanitized) {
|
|
89
|
+
// Persist the cleaned history to free up localStorage space
|
|
90
|
+
// Use try/catch so sanitization failure doesn't lose loaded history
|
|
91
|
+
try {
|
|
92
|
+
await persistHistory(history);
|
|
93
|
+
} catch (sanitizeError) {
|
|
94
|
+
console.warn('Storage: Failed to persist sanitized history, continuing with in-memory version', sanitizeError);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return history;
|
|
98
|
+
}
|
|
99
|
+
} catch (loadError) {
|
|
100
|
+
console.error('Storage: Failed to load history', loadError);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Persist history to storage
|
|
107
|
+
async function persistHistory(history) {
|
|
108
|
+
const core = getCore();
|
|
109
|
+
const cryptoMod = getCrypto();
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
if (core.encryptionEnabled && core.currentPassword && cryptoMod) {
|
|
113
|
+
const encrypted = await cryptoMod.encrypt(history, core.currentPassword);
|
|
114
|
+
localStorage.setItem(ENCRYPTED_KEY, JSON.stringify(encrypted));
|
|
115
|
+
localStorage.removeItem(HISTORY_KEY); // Remove plain version
|
|
116
|
+
} else {
|
|
117
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
|
118
|
+
}
|
|
119
|
+
} catch (persistError) {
|
|
120
|
+
console.error('Storage: Failed to persist history', persistError);
|
|
121
|
+
|
|
122
|
+
// If quota exceeded, try to recover by stripping image URLs and retrying
|
|
123
|
+
if (persistError.name === 'QuotaExceededError' || persistError.code === 22) {
|
|
124
|
+
console.warn('Storage: Quota exceeded, stripping image URLs and retrying...');
|
|
125
|
+
for (const msg of history) {
|
|
126
|
+
if (msg.imageUrl && (msg.imageUrl.startsWith('data:') || msg.imageUrl === '__pending_upload__')) {
|
|
127
|
+
msg.imageUrl = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
|
132
|
+
console.log('Storage: Recovery successful after stripping image URLs');
|
|
133
|
+
} catch (retryError) {
|
|
134
|
+
console.error('Storage: Recovery failed, trimming history...', retryError);
|
|
135
|
+
// Last resort: keep only recent messages
|
|
136
|
+
const trimmed = history.slice(-50);
|
|
137
|
+
try {
|
|
138
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed));
|
|
139
|
+
console.log('Storage: Recovery successful after trimming to 50 messages');
|
|
140
|
+
} catch (finalError) {
|
|
141
|
+
console.error('Storage: All recovery attempts failed', finalError);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Clear history
|
|
149
|
+
function clearHistory() {
|
|
150
|
+
localStorage.removeItem(HISTORY_KEY);
|
|
151
|
+
localStorage.removeItem(ENCRYPTED_KEY);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Migrate history between encrypted/unencrypted
|
|
155
|
+
async function migrateHistory(toEncrypted, newPassword) {
|
|
156
|
+
const history = await loadHistory();
|
|
157
|
+
const cryptoMod = getCrypto();
|
|
158
|
+
|
|
159
|
+
if (toEncrypted && newPassword && cryptoMod) {
|
|
160
|
+
const encrypted = await cryptoMod.encrypt(history, newPassword);
|
|
161
|
+
localStorage.setItem(ENCRYPTED_KEY, JSON.stringify(encrypted));
|
|
162
|
+
localStorage.removeItem(HISTORY_KEY);
|
|
163
|
+
} else {
|
|
164
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
|
165
|
+
localStorage.removeItem(ENCRYPTED_KEY);
|
|
166
|
+
if (cryptoMod) cryptoMod.clearEncryption();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Save settings
|
|
171
|
+
function saveSettings(settings) {
|
|
172
|
+
try {
|
|
173
|
+
const existing = loadSettings();
|
|
174
|
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...existing, ...settings }));
|
|
175
|
+
} catch (saveError) {
|
|
176
|
+
console.error('Storage: Failed to save settings', saveError);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Load settings
|
|
181
|
+
function loadSettings() {
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
|
|
184
|
+
} catch (parseError) {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ===========================================
|
|
190
|
+
// CROSS-DEVICE SYNC
|
|
191
|
+
// ===========================================
|
|
192
|
+
|
|
193
|
+
// Get auth headers for sync API calls
|
|
194
|
+
function getAuthHeaders() {
|
|
195
|
+
const settings = JSON.parse(localStorage.getItem('uplink-settings') || '{}');
|
|
196
|
+
const token = settings.gatewayToken || '';
|
|
197
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
198
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
199
|
+
return headers;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Generate sync ID from password (deterministic hash)
|
|
203
|
+
async function generateSyncId(password) {
|
|
204
|
+
const encoder = new TextEncoder();
|
|
205
|
+
const data = encoder.encode('uplink-sync:' + password);
|
|
206
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
207
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
208
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Push encrypted history to server
|
|
212
|
+
async function pushSync() {
|
|
213
|
+
const core = getCore();
|
|
214
|
+
const cryptoMod = getCrypto();
|
|
215
|
+
|
|
216
|
+
if (!core.encryptionEnabled || !core.currentPassword) {
|
|
217
|
+
throw new Error('Encryption must be enabled for sync');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const history = await loadHistory();
|
|
221
|
+
const satellites = localStorage.getItem('uplink-satellites');
|
|
222
|
+
|
|
223
|
+
// Bundle all sync data
|
|
224
|
+
const syncData = {
|
|
225
|
+
history,
|
|
226
|
+
satellites: satellites ? JSON.parse(satellites) : null,
|
|
227
|
+
settings: loadSettings(),
|
|
228
|
+
syncedAt: Date.now()
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Encrypt the bundle
|
|
232
|
+
const encrypted = await cryptoMod.encrypt(syncData, core.currentPassword);
|
|
233
|
+
const syncId = await generateSyncId(core.currentPassword);
|
|
234
|
+
|
|
235
|
+
const res = await fetch('/api/sync/push', {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: getAuthHeaders(),
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
syncId,
|
|
240
|
+
encryptedData: encrypted,
|
|
241
|
+
timestamp: Date.now()
|
|
242
|
+
})
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (!res.ok) throw new Error('Sync push failed');
|
|
246
|
+
return await res.json();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Pull encrypted history from server and decrypt
|
|
250
|
+
async function pullSync() {
|
|
251
|
+
const core = getCore();
|
|
252
|
+
const cryptoMod = getCrypto();
|
|
253
|
+
|
|
254
|
+
if (!core.encryptionEnabled || !core.currentPassword) {
|
|
255
|
+
throw new Error('Encryption must be enabled for sync');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const syncId = await generateSyncId(core.currentPassword);
|
|
259
|
+
|
|
260
|
+
const res = await fetch(`/api/sync/pull?syncId=${syncId}`, {
|
|
261
|
+
headers: getAuthHeaders(),
|
|
262
|
+
});
|
|
263
|
+
if (!res.ok) throw new Error('Sync pull failed');
|
|
264
|
+
|
|
265
|
+
const data = await res.json();
|
|
266
|
+
if (!data.encryptedData) {
|
|
267
|
+
return null; // No sync data found
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Decrypt the bundle
|
|
271
|
+
const syncData = await cryptoMod.decrypt(data.encryptedData, core.currentPassword);
|
|
272
|
+
|
|
273
|
+
return syncData;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check if sync data exists
|
|
277
|
+
async function checkSync() {
|
|
278
|
+
const core = getCore();
|
|
279
|
+
|
|
280
|
+
if (!core.encryptionEnabled || !core.currentPassword) {
|
|
281
|
+
return { exists: false, reason: 'encryption_disabled' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const syncId = await generateSyncId(core.currentPassword);
|
|
285
|
+
|
|
286
|
+
const res = await fetch(`/api/sync/check?syncId=${syncId}`, {
|
|
287
|
+
headers: getAuthHeaders(),
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) return { exists: false, reason: 'check_failed' };
|
|
290
|
+
|
|
291
|
+
return await res.json();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Apply sync data (merge or replace)
|
|
295
|
+
async function applySync(syncData, mode = 'replace') {
|
|
296
|
+
if (!syncData) return;
|
|
297
|
+
|
|
298
|
+
if (mode === 'replace') {
|
|
299
|
+
// Replace local data with synced data
|
|
300
|
+
if (syncData.history) {
|
|
301
|
+
await persistHistory(syncData.history);
|
|
302
|
+
}
|
|
303
|
+
if (syncData.satellites) {
|
|
304
|
+
localStorage.setItem('uplink-satellites', JSON.stringify(syncData.satellites));
|
|
305
|
+
}
|
|
306
|
+
if (syncData.settings) {
|
|
307
|
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify(syncData.settings));
|
|
308
|
+
}
|
|
309
|
+
} else if (mode === 'merge') {
|
|
310
|
+
// Merge histories (dedupe by timestamp)
|
|
311
|
+
const local = await loadHistory();
|
|
312
|
+
const remote = syncData.history || [];
|
|
313
|
+
const merged = mergeHistories(local, remote);
|
|
314
|
+
await persistHistory(merged);
|
|
315
|
+
|
|
316
|
+
// For satellites and settings, prefer remote if newer
|
|
317
|
+
if (syncData.satellites && syncData.syncedAt > (loadSettings().lastSyncedAt || 0)) {
|
|
318
|
+
localStorage.setItem('uplink-satellites', JSON.stringify(syncData.satellites));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Record last sync time
|
|
323
|
+
saveSettings({ lastSyncedAt: Date.now() });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Merge two histories, deduping by timestamp
|
|
327
|
+
function mergeHistories(local, remote) {
|
|
328
|
+
const seen = new Set();
|
|
329
|
+
const merged = [];
|
|
330
|
+
|
|
331
|
+
[...local, ...remote]
|
|
332
|
+
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
|
|
333
|
+
.forEach(msg => {
|
|
334
|
+
const key = `${msg.timestamp}-${msg.type}-${(msg.text || '').substring(0, 50)}`;
|
|
335
|
+
if (!seen.has(key)) {
|
|
336
|
+
seen.add(key);
|
|
337
|
+
merged.push(msg);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return merged.slice(-MAX_MESSAGES);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Update the last user image message's URL from data URL to server URL
|
|
346
|
+
* Prevents localStorage bloat from storing full base64 images
|
|
347
|
+
*/
|
|
348
|
+
async function updateLastImageUrl(serverUrl) {
|
|
349
|
+
saveQueue = saveQueue.then(async () => {
|
|
350
|
+
let history = await loadHistory();
|
|
351
|
+
|
|
352
|
+
// Find last user message with a pending or data URL image
|
|
353
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
354
|
+
const url = history[i].imageUrl;
|
|
355
|
+
if (history[i].type === 'user' && url && (url === '__pending_upload__' || url.startsWith('data:'))) {
|
|
356
|
+
history[i].imageUrl = serverUrl;
|
|
357
|
+
await persistHistory(history);
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}).catch(err => {
|
|
362
|
+
console.error('Storage: updateLastImageUrl error:', err);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return saveQueue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Export API
|
|
369
|
+
export const UplinkStorage = {
|
|
370
|
+
saveMessage,
|
|
371
|
+
loadHistory,
|
|
372
|
+
clearHistory,
|
|
373
|
+
migrateHistory,
|
|
374
|
+
saveSettings,
|
|
375
|
+
loadSettings,
|
|
376
|
+
updateLastImageUrl,
|
|
377
|
+
// Sync functions
|
|
378
|
+
pushSync,
|
|
379
|
+
pullSync,
|
|
380
|
+
checkSync,
|
|
381
|
+
applySync,
|
|
382
|
+
generateSyncId,
|
|
383
|
+
MAX_MESSAGES
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
export {
|
|
387
|
+
saveMessage,
|
|
388
|
+
loadHistory,
|
|
389
|
+
clearHistory,
|
|
390
|
+
migrateHistory,
|
|
391
|
+
saveSettings,
|
|
392
|
+
loadSettings,
|
|
393
|
+
updateLastImageUrl,
|
|
394
|
+
pushSync,
|
|
395
|
+
pullSync,
|
|
396
|
+
checkSync,
|
|
397
|
+
applySync,
|
|
398
|
+
generateSyncId,
|
|
399
|
+
MAX_MESSAGES
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Backward compat: assign to window
|
|
403
|
+
window.UplinkStorage = UplinkStorage;
|
|
404
|
+
|
|
405
|
+
// Register with core
|
|
406
|
+
UplinkCore.registerModule('storage');
|
|
407
|
+
|
|
408
|
+
console.log('Storage: Loaded');
|