@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
package/server/chat.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Module - AI chat functions, transcription, parallel TTS
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { log, fetchWithTimeout, withRetry } from './utils.js';
|
|
8
|
+
import { textToSpeech, textToSpeechLocal, textToSpeechEdge, textToSpeechOpenAI, textToSpeechPiper, extractFirstSentence } from './tts.js';
|
|
9
|
+
import { GATEWAY_URL as STATIC_GATEWAY_URL, GATEWAY_TOKEN as STATIC_GATEWAY_TOKEN, ROOT_DIR, SESSION_USER, REQUEST_TIMEOUT } from './config.js';
|
|
10
|
+
import { loadConfig } from './runtime-config.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get gateway URL and token dynamically (includes auto-discovered values)
|
|
14
|
+
*/
|
|
15
|
+
async function getGatewayConfig() {
|
|
16
|
+
try {
|
|
17
|
+
const config = await loadConfig();
|
|
18
|
+
return {
|
|
19
|
+
GATEWAY_URL: config.gatewayUrl || STATIC_GATEWAY_URL,
|
|
20
|
+
GATEWAY_TOKEN: config.gatewayToken || STATIC_GATEWAY_TOKEN,
|
|
21
|
+
};
|
|
22
|
+
} catch {
|
|
23
|
+
return { GATEWAY_URL: STATIC_GATEWAY_URL, GATEWAY_TOKEN: STATIC_GATEWAY_TOKEN };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
import { transcribe } from './stt/index.js';
|
|
27
|
+
import { broadcastToAll } from './websocket/index.js';
|
|
28
|
+
|
|
29
|
+
const AUDIO_OUTPUT_DIR = path.join(ROOT_DIR, 'public', 'audio');
|
|
30
|
+
|
|
31
|
+
// Helper to pick the right TTS function based on config
|
|
32
|
+
async function getTTSFunction() {
|
|
33
|
+
const config = await loadConfig();
|
|
34
|
+
switch (config.ttsProvider) {
|
|
35
|
+
case 'local':
|
|
36
|
+
return textToSpeechLocal;
|
|
37
|
+
case 'edge':
|
|
38
|
+
return textToSpeechEdge;
|
|
39
|
+
case 'openai':
|
|
40
|
+
return textToSpeechOpenAI;
|
|
41
|
+
case 'piper':
|
|
42
|
+
return textToSpeechPiper;
|
|
43
|
+
case 'elevenlabs':
|
|
44
|
+
default:
|
|
45
|
+
return textToSpeech;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Re-export transcribe from STT module (used by routes.js deps)
|
|
50
|
+
export { transcribe };
|
|
51
|
+
|
|
52
|
+
// ===========================================
|
|
53
|
+
// Basic Chat (Non-streaming)
|
|
54
|
+
// ===========================================
|
|
55
|
+
|
|
56
|
+
export async function chat(message, sessionUser = SESSION_USER, mode = 'voice') {
|
|
57
|
+
return withRetry(async () => {
|
|
58
|
+
const gw = await getGatewayConfig();
|
|
59
|
+
const prefix = mode === 'voice'
|
|
60
|
+
? '[Voice chat - keep response brief and conversational, 1-2 sentences max] '
|
|
61
|
+
: '[Text chat via Uplink] ';
|
|
62
|
+
|
|
63
|
+
// Use canonical session key format matching channel.js
|
|
64
|
+
const sessionKey = 'agent:main:main';
|
|
65
|
+
|
|
66
|
+
const response = await fetchWithTimeout(`${gw.GATEWAY_URL}/v1/chat/completions`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'Authorization': `Bearer ${gw.GATEWAY_TOKEN}`,
|
|
71
|
+
'x-openclaw-session-key': sessionKey
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model: 'openclaw',
|
|
75
|
+
user: sessionUser,
|
|
76
|
+
messages: [{
|
|
77
|
+
role: 'user',
|
|
78
|
+
content: `${prefix}${message}`
|
|
79
|
+
}]
|
|
80
|
+
})
|
|
81
|
+
}, REQUEST_TIMEOUT);
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const text = await response.text();
|
|
85
|
+
throw new Error(`Chat API error: ${response.status} - ${text}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const data = await response.json();
|
|
89
|
+
return data.choices?.[0]?.message?.content || 'No response';
|
|
90
|
+
}, { maxRetries: 3 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ===========================================
|
|
94
|
+
// Parallel TTS Chat (Streaming with early TTS)
|
|
95
|
+
// ===========================================
|
|
96
|
+
|
|
97
|
+
export async function chatWithParallelTTS(message, sessionUser = SESSION_USER) {
|
|
98
|
+
const gw = await getGatewayConfig();
|
|
99
|
+
const prefix = '[Voice chat - respond naturally and conversationally] ';
|
|
100
|
+
|
|
101
|
+
// Use canonical session key format matching channel.js
|
|
102
|
+
const sessionKey = 'agent:main:main';
|
|
103
|
+
|
|
104
|
+
const response = await fetchWithTimeout(`${gw.GATEWAY_URL}/v1/chat/completions`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
'Content-Type': 'application/json',
|
|
108
|
+
'Authorization': `Bearer ${gw.GATEWAY_TOKEN}`,
|
|
109
|
+
'x-openclaw-session-key': sessionKey
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
model: 'openclaw',
|
|
113
|
+
user: sessionUser,
|
|
114
|
+
stream: true,
|
|
115
|
+
messages: [{
|
|
116
|
+
role: 'user',
|
|
117
|
+
content: `${prefix}${message}`
|
|
118
|
+
}]
|
|
119
|
+
})
|
|
120
|
+
}, REQUEST_TIMEOUT);
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const text = await response.text();
|
|
124
|
+
throw new Error(`Chat API error: ${response.status} - ${text}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let fullResponse = '';
|
|
128
|
+
let processedUpTo = 0; // Track how much text we've already sent to TTS
|
|
129
|
+
const ttsPromises = []; // All TTS generation promises
|
|
130
|
+
let sentenceBatch = []; // Accumulate sentences before firing TTS
|
|
131
|
+
let batchCharCount = 0;
|
|
132
|
+
let isFirstChunk = true;
|
|
133
|
+
|
|
134
|
+
// Batching config:
|
|
135
|
+
// First chunk: send after 1 sentence (fast first audio)
|
|
136
|
+
// Subsequent chunks: batch 2-3 sentences (~200 chars) for efficiency
|
|
137
|
+
const BATCH_TARGET_CHARS = 200;
|
|
138
|
+
const MAX_BATCH_SENTENCES = 3;
|
|
139
|
+
|
|
140
|
+
function flushBatch() {
|
|
141
|
+
if (sentenceBatch.length === 0) return;
|
|
142
|
+
const text = sentenceBatch.join(' ');
|
|
143
|
+
const chunkIndex = ttsPromises.length;
|
|
144
|
+
log('debug', `[Chunked TTS] Chunk ${chunkIndex + 1} (${sentenceBatch.length} sentences, ${text.length} chars): "${text.substring(0, 80)}..."`);
|
|
145
|
+
|
|
146
|
+
// Broadcast "speaking" status on first TTS chunk
|
|
147
|
+
if (chunkIndex === 0) {
|
|
148
|
+
try { broadcastToAll({ type: 'voiceStatus', stage: 'speaking', label: 'Generating speech...' }); } catch (e) {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const promise = (async () => {
|
|
152
|
+
const tts = await getTTSFunction();
|
|
153
|
+
return tts(text, AUDIO_OUTPUT_DIR);
|
|
154
|
+
})().catch(ttsError => {
|
|
155
|
+
log('error', `[Chunked TTS] Chunk ${chunkIndex + 1} failed:`, ttsError.message);
|
|
156
|
+
return null;
|
|
157
|
+
});
|
|
158
|
+
ttsPromises.push(promise);
|
|
159
|
+
sentenceBatch = [];
|
|
160
|
+
batchCharCount = 0;
|
|
161
|
+
isFirstChunk = false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const reader = response.body.getReader();
|
|
165
|
+
const decoder = new TextDecoder();
|
|
166
|
+
let buffer = '';
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
while (true) {
|
|
170
|
+
const { done, value } = await reader.read();
|
|
171
|
+
if (done) break;
|
|
172
|
+
|
|
173
|
+
buffer += decoder.decode(value, { stream: true });
|
|
174
|
+
const lines = buffer.split('\n');
|
|
175
|
+
buffer = lines.pop() || '';
|
|
176
|
+
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
if (line.startsWith('data: ')) {
|
|
179
|
+
const data = line.slice(6);
|
|
180
|
+
if (data === '[DONE]') continue;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(data);
|
|
183
|
+
const content = parsed.choices?.[0]?.delta?.content || '';
|
|
184
|
+
if (content) {
|
|
185
|
+
fullResponse += content;
|
|
186
|
+
|
|
187
|
+
// Extract ALL complete sentences from unprocessed text
|
|
188
|
+
let keepExtracting = true;
|
|
189
|
+
while (keepExtracting) {
|
|
190
|
+
const unprocessed = fullResponse.slice(processedUpTo);
|
|
191
|
+
const extracted = extractFirstSentence(unprocessed);
|
|
192
|
+
if (extracted) {
|
|
193
|
+
const sentence = extracted.sentence;
|
|
194
|
+
// Move processedUpTo past this sentence
|
|
195
|
+
const sentenceEnd = unprocessed.indexOf(sentence) + sentence.length;
|
|
196
|
+
processedUpTo += sentenceEnd;
|
|
197
|
+
// Skip whitespace
|
|
198
|
+
while (processedUpTo < fullResponse.length && /\s/.test(fullResponse[processedUpTo])) processedUpTo++;
|
|
199
|
+
|
|
200
|
+
sentenceBatch.push(sentence);
|
|
201
|
+
batchCharCount += sentence.length;
|
|
202
|
+
|
|
203
|
+
// Flush batch: immediately for first chunk, or when batch is full
|
|
204
|
+
if (isFirstChunk || batchCharCount >= BATCH_TARGET_CHARS || sentenceBatch.length >= MAX_BATCH_SENTENCES) {
|
|
205
|
+
flushBatch();
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
keepExtracting = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (parseError) {
|
|
213
|
+
// Skip unparseable chunks
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
reader.releaseLock();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Flush any remaining batched sentences
|
|
223
|
+
flushBatch();
|
|
224
|
+
|
|
225
|
+
// Handle any remaining text that didn't end with punctuation
|
|
226
|
+
const remaining = fullResponse.slice(processedUpTo).trim();
|
|
227
|
+
if (remaining) {
|
|
228
|
+
const chunkIndex = ttsPromises.length;
|
|
229
|
+
log('debug', `[Chunked TTS] Final chunk ${chunkIndex + 1}: "${remaining}"`);
|
|
230
|
+
const promise = (async () => {
|
|
231
|
+
const tts = await getTTSFunction();
|
|
232
|
+
return tts(remaining, AUDIO_OUTPUT_DIR);
|
|
233
|
+
})().catch(ttsError => {
|
|
234
|
+
log('error', `[Chunked TTS] Final chunk failed:`, ttsError.message);
|
|
235
|
+
return null;
|
|
236
|
+
});
|
|
237
|
+
ttsPromises.push(promise);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// If no sentences were extracted at all, TTS the whole thing
|
|
241
|
+
if (ttsPromises.length === 0 && fullResponse) {
|
|
242
|
+
try {
|
|
243
|
+
const tts = await getTTSFunction();
|
|
244
|
+
const url = await tts(fullResponse, AUDIO_OUTPUT_DIR);
|
|
245
|
+
ttsPromises.push(Promise.resolve(url));
|
|
246
|
+
} catch (ttsError) {
|
|
247
|
+
log('error', '[Chunked TTS] Full response TTS failed:', ttsError.message);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Wait for all TTS chunks and collect URLs (in order)
|
|
252
|
+
const audioUrls = (await Promise.all(ttsPromises)).filter(Boolean);
|
|
253
|
+
|
|
254
|
+
log('info', `[Chunked TTS] Generated ${audioUrls.length} audio chunks for response`);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
response: fullResponse || 'No response',
|
|
258
|
+
audioUrl: audioUrls[0] || null, // Backward compat: first chunk
|
|
259
|
+
audioUrls // New: all chunks for queue
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ===========================================
|
|
264
|
+
// TTS Wrapper (for routes)
|
|
265
|
+
// ===========================================
|
|
266
|
+
|
|
267
|
+
export async function generateTTS(text) {
|
|
268
|
+
const tts = await getTTSFunction();
|
|
269
|
+
return tts(text, AUDIO_OUTPUT_DIR);
|
|
270
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Store Module
|
|
3
|
+
* ===================
|
|
4
|
+
* Encrypted configuration storage for Uplink.
|
|
5
|
+
* Replaces .env file editing with a secure, file-based config system.
|
|
6
|
+
*
|
|
7
|
+
* Storage layout:
|
|
8
|
+
* config/config.json — Non-sensitive preferences + encrypted secrets blob
|
|
9
|
+
* config/config.backup.json — Auto-backup before migrations
|
|
10
|
+
*
|
|
11
|
+
* Uses AES-256-GCM with scrypt key derivation for encryption.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import crypto from 'crypto';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { ROOT_DIR } from './config.js';
|
|
18
|
+
import { createLogger } from './logger.js';
|
|
19
|
+
|
|
20
|
+
const log = createLogger('config-store');
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const CONFIG_DIR = path.join(ROOT_DIR, 'config');
|
|
26
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
27
|
+
const BACKUP_FILE = path.join(CONFIG_DIR, 'config.backup.json');
|
|
28
|
+
|
|
29
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
30
|
+
const SCRYPT_N = 16384;
|
|
31
|
+
const SCRYPT_R = 8;
|
|
32
|
+
const SCRYPT_P = 1;
|
|
33
|
+
const KEY_LEN = 32;
|
|
34
|
+
const SALT_LEN = 32;
|
|
35
|
+
const IV_LEN = 16;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Default config structure
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
const DEFAULT_CONFIG = {
|
|
41
|
+
version: 1,
|
|
42
|
+
onboarding: {
|
|
43
|
+
completed: false,
|
|
44
|
+
currentStep: 0,
|
|
45
|
+
steps: {},
|
|
46
|
+
},
|
|
47
|
+
preferences: {
|
|
48
|
+
theme: 'dark',
|
|
49
|
+
fontSize: 14,
|
|
50
|
+
language: 'en',
|
|
51
|
+
notifications: true,
|
|
52
|
+
},
|
|
53
|
+
connections: {
|
|
54
|
+
gatewayUrl: '',
|
|
55
|
+
gatewayToken: '',
|
|
56
|
+
autoReconnect: true,
|
|
57
|
+
reconnectIntervalMs: 5000,
|
|
58
|
+
},
|
|
59
|
+
voice: {
|
|
60
|
+
enabled: false,
|
|
61
|
+
ttsProvider: 'elevenlabs',
|
|
62
|
+
ttsVoice: '',
|
|
63
|
+
volume: 0.8,
|
|
64
|
+
speed: 1.0,
|
|
65
|
+
},
|
|
66
|
+
security: {
|
|
67
|
+
passwordHash: '',
|
|
68
|
+
lockTimeoutMin: 15,
|
|
69
|
+
requirePasswordOnStart: true,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// In-memory config state
|
|
74
|
+
let _config = structuredClone(DEFAULT_CONFIG);
|
|
75
|
+
let _initialized = false;
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Crypto helpers
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function deriveKey(password, salt) {
|
|
82
|
+
return crypto.scryptSync(password, salt, KEY_LEN, {
|
|
83
|
+
N: SCRYPT_N,
|
|
84
|
+
r: SCRYPT_R,
|
|
85
|
+
p: SCRYPT_P,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function encrypt(plaintext, password) {
|
|
90
|
+
const salt = crypto.randomBytes(SALT_LEN);
|
|
91
|
+
const iv = crypto.randomBytes(IV_LEN);
|
|
92
|
+
const key = deriveKey(password, salt);
|
|
93
|
+
|
|
94
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
95
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
96
|
+
const tag = cipher.getAuthTag();
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
salt: salt.toString('base64'),
|
|
100
|
+
iv: iv.toString('base64'),
|
|
101
|
+
tag: tag.toString('base64'),
|
|
102
|
+
ciphertext: encrypted.toString('base64'),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decrypt(envelope, password) {
|
|
107
|
+
const salt = Buffer.from(envelope.salt, 'base64');
|
|
108
|
+
const iv = Buffer.from(envelope.iv, 'base64');
|
|
109
|
+
const tag = Buffer.from(envelope.tag, 'base64');
|
|
110
|
+
const data = Buffer.from(envelope.ciphertext, 'base64');
|
|
111
|
+
const key = deriveKey(password, salt);
|
|
112
|
+
|
|
113
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
114
|
+
decipher.setAuthTag(tag);
|
|
115
|
+
|
|
116
|
+
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
117
|
+
return decrypted.toString('utf8');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// File I/O
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function ensureConfigDir() {
|
|
125
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
126
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readConfigFile() {
|
|
131
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
132
|
+
try {
|
|
133
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
134
|
+
return JSON.parse(raw);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log.error('Failed to read config file:', err.message);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeConfigFile(data) {
|
|
142
|
+
ensureConfigDir();
|
|
143
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function backupConfigFile() {
|
|
147
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
148
|
+
ensureConfigDir();
|
|
149
|
+
fs.copyFileSync(CONFIG_FILE, BACKUP_FILE);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Public API
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/** Initialize the config store. Loads from disk or creates defaults. */
|
|
158
|
+
export function init() {
|
|
159
|
+
if (_initialized) return;
|
|
160
|
+
|
|
161
|
+
const stored = readConfigFile();
|
|
162
|
+
if (stored) {
|
|
163
|
+
// Merge stored config over defaults (handles new fields added in updates)
|
|
164
|
+
_config = mergeDeep(structuredClone(DEFAULT_CONFIG), stored);
|
|
165
|
+
log.info('Loaded configuration from disk');
|
|
166
|
+
} else {
|
|
167
|
+
_config = structuredClone(DEFAULT_CONFIG);
|
|
168
|
+
log.info('No config file found, using defaults');
|
|
169
|
+
}
|
|
170
|
+
_initialized = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Check if config store has been initialized */
|
|
174
|
+
export function isInitialized() {
|
|
175
|
+
return _initialized;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Check if onboarding has been completed */
|
|
179
|
+
export function isOnboarded() {
|
|
180
|
+
return _config.onboarding.completed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Return the full internal config (use with care — contains secrets). */
|
|
184
|
+
export function getRaw() {
|
|
185
|
+
return structuredClone(_config);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Return a client-safe copy with sensitive fields stripped. */
|
|
189
|
+
export function getSafe() {
|
|
190
|
+
const safe = structuredClone(_config);
|
|
191
|
+
if (safe.connections) delete safe.connections.gatewayToken;
|
|
192
|
+
if (safe.security) delete safe.security.passwordHash;
|
|
193
|
+
return safe;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Get a single config value by dot-path (e.g. 'connections.gatewayUrl') */
|
|
197
|
+
export function get(keyPath) {
|
|
198
|
+
const parts = keyPath.split('.');
|
|
199
|
+
let current = _config;
|
|
200
|
+
for (const part of parts) {
|
|
201
|
+
if (current === null || current === undefined) return undefined;
|
|
202
|
+
current = current[part];
|
|
203
|
+
}
|
|
204
|
+
return current;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Set a single config value by dot-path */
|
|
208
|
+
export function set(keyPath, value) {
|
|
209
|
+
const parts = keyPath.split('.');
|
|
210
|
+
let current = _config;
|
|
211
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
212
|
+
if (current[parts[i]] === undefined) current[parts[i]] = {};
|
|
213
|
+
current = current[parts[i]];
|
|
214
|
+
}
|
|
215
|
+
current[parts[parts.length - 1]] = value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Merge a partial object into a config section. Returns updated safe config. */
|
|
219
|
+
export function updateSection(section, partial) {
|
|
220
|
+
if (!_config[section] || typeof _config[section] !== 'object') {
|
|
221
|
+
throw new Error(`Unknown config section: ${section}`);
|
|
222
|
+
}
|
|
223
|
+
Object.assign(_config[section], partial);
|
|
224
|
+
return getSafe();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Save onboarding step data */
|
|
228
|
+
export function saveOnboardingStep(stepNumber, data) {
|
|
229
|
+
_config.onboarding.steps[stepNumber] = data;
|
|
230
|
+
_config.onboarding.currentStep = Math.max(
|
|
231
|
+
_config.onboarding.currentStep,
|
|
232
|
+
Number(stepNumber),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Mark onboarding complete */
|
|
237
|
+
export function completeOnboarding() {
|
|
238
|
+
_config.onboarding.completed = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Persist config to disk */
|
|
242
|
+
export async function save() {
|
|
243
|
+
backupConfigFile();
|
|
244
|
+
writeConfigFile(_config);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Hash and store a new master password */
|
|
248
|
+
export function setPassword(password) {
|
|
249
|
+
const salt = crypto.randomBytes(SALT_LEN);
|
|
250
|
+
const hash = deriveKey(password, salt).toString('hex');
|
|
251
|
+
_config.security.passwordHash = salt.toString('hex') + ':' + hash;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Verify a password against stored hash */
|
|
255
|
+
export function verifyPassword(password) {
|
|
256
|
+
if (!_config.security.passwordHash) return true; // no password set
|
|
257
|
+
const [saltHex, hashHex] = _config.security.passwordHash.split(':');
|
|
258
|
+
const salt = Buffer.from(saltHex, 'hex');
|
|
259
|
+
const derived = deriveKey(password, salt).toString('hex');
|
|
260
|
+
return derived === hashHex;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Export encrypted config for backup/transfer */
|
|
264
|
+
export function exportEncrypted(password) {
|
|
265
|
+
const plaintext = JSON.stringify(_config);
|
|
266
|
+
return {
|
|
267
|
+
version: 1,
|
|
268
|
+
exportedAt: new Date().toISOString(),
|
|
269
|
+
...encrypt(plaintext, password),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Import and restore config from encrypted backup */
|
|
274
|
+
export function importEncrypted(envelope, password) {
|
|
275
|
+
const plaintext = decrypt(envelope, password);
|
|
276
|
+
const parsed = JSON.parse(plaintext);
|
|
277
|
+
_config = mergeDeep(structuredClone(DEFAULT_CONFIG), parsed);
|
|
278
|
+
return getSafe();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Migrate settings from existing .env file */
|
|
282
|
+
export function migrateFromEnv() {
|
|
283
|
+
const envPath = path.join(ROOT_DIR, '.env');
|
|
284
|
+
if (!fs.existsSync(envPath)) return false;
|
|
285
|
+
|
|
286
|
+
log.info('Found .env file, migrating...');
|
|
287
|
+
|
|
288
|
+
// Read current env vars (already loaded by dotenv in config.js)
|
|
289
|
+
const updates = {};
|
|
290
|
+
|
|
291
|
+
if (process.env.GATEWAY_URL) {
|
|
292
|
+
set('connections.gatewayUrl', process.env.GATEWAY_URL);
|
|
293
|
+
}
|
|
294
|
+
if (process.env.GATEWAY_TOKEN) {
|
|
295
|
+
set('connections.gatewayToken', process.env.GATEWAY_TOKEN);
|
|
296
|
+
}
|
|
297
|
+
if (process.env.ELEVENLABS_API_KEY) {
|
|
298
|
+
set('voice.enabled', true);
|
|
299
|
+
set('voice.ttsProvider', 'elevenlabs');
|
|
300
|
+
}
|
|
301
|
+
if (process.env.ELEVENLABS_VOICE_ID) {
|
|
302
|
+
set('voice.ttsVoice', process.env.ELEVENLABS_VOICE_ID);
|
|
303
|
+
}
|
|
304
|
+
if (process.env.SESSION_USER) {
|
|
305
|
+
set('preferences.sessionUser', process.env.SESSION_USER);
|
|
306
|
+
}
|
|
307
|
+
if (process.env.ASSISTANT_NAME) {
|
|
308
|
+
set('preferences.assistantName', process.env.ASSISTANT_NAME);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Rename .env to .env.backup
|
|
312
|
+
const backupPath = path.join(ROOT_DIR, '.env.backup');
|
|
313
|
+
try {
|
|
314
|
+
fs.renameSync(envPath, backupPath);
|
|
315
|
+
log.info('.env renamed to .env.backup');
|
|
316
|
+
} catch (err) {
|
|
317
|
+
log.warn('Could not rename .env:', err.message);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Utility
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/** Deep merge source into target (target values are overwritten) */
|
|
328
|
+
function mergeDeep(target, source) {
|
|
329
|
+
for (const key of Object.keys(source)) {
|
|
330
|
+
if (
|
|
331
|
+
source[key] &&
|
|
332
|
+
typeof source[key] === 'object' &&
|
|
333
|
+
!Array.isArray(source[key]) &&
|
|
334
|
+
target[key] &&
|
|
335
|
+
typeof target[key] === 'object'
|
|
336
|
+
) {
|
|
337
|
+
mergeDeep(target[key], source[key]);
|
|
338
|
+
} else {
|
|
339
|
+
target[key] = source[key];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return target;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export default {
|
|
346
|
+
init,
|
|
347
|
+
isInitialized,
|
|
348
|
+
isOnboarded,
|
|
349
|
+
getRaw,
|
|
350
|
+
getSafe,
|
|
351
|
+
get,
|
|
352
|
+
set,
|
|
353
|
+
updateSection,
|
|
354
|
+
saveOnboardingStep,
|
|
355
|
+
completeOnboarding,
|
|
356
|
+
save,
|
|
357
|
+
setPassword,
|
|
358
|
+
verifyPassword,
|
|
359
|
+
exportEncrypted,
|
|
360
|
+
importEncrypted,
|
|
361
|
+
migrateFromEnv,
|
|
362
|
+
};
|