@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,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Config Module
|
|
3
|
+
*
|
|
4
|
+
* Manages runtime configuration stored in config.json.
|
|
5
|
+
* Falls back to environment variables for backwards compatibility.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import lockfile from 'proper-lockfile';
|
|
12
|
+
import { isEdgeTTSAvailable } from './tts.js';
|
|
13
|
+
import { discoverGateway, getConfigPath } from './openclaw-discover.js';
|
|
14
|
+
import { createLogger } from './logger.js';
|
|
15
|
+
|
|
16
|
+
const log = createLogger('Config');
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
|
|
20
|
+
|
|
21
|
+
// Default configuration
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
// Identity
|
|
24
|
+
userName: '',
|
|
25
|
+
assistantName: 'Assistant',
|
|
26
|
+
|
|
27
|
+
// Gateway
|
|
28
|
+
gatewayUrl: 'http://127.0.0.1:18789',
|
|
29
|
+
gatewayToken: '',
|
|
30
|
+
|
|
31
|
+
// Session
|
|
32
|
+
sessionUser: 'uplink-user',
|
|
33
|
+
|
|
34
|
+
// TTS
|
|
35
|
+
ttsProvider: 'none', // 'none', 'elevenlabs', 'openai', 'edge', 'piper', 'local'
|
|
36
|
+
elevenLabsApiKey: '',
|
|
37
|
+
elevenLabsVoiceId: '',
|
|
38
|
+
ttsVoiceName: 'Assistant',
|
|
39
|
+
|
|
40
|
+
// OpenAI TTS
|
|
41
|
+
openaiApiKey: '',
|
|
42
|
+
openaiTtsVoice: 'nova', // alloy, echo, fable, onyx, nova, shimmer
|
|
43
|
+
openaiTtsModel: 'tts-1', // tts-1, tts-1-hd
|
|
44
|
+
|
|
45
|
+
// Edge TTS
|
|
46
|
+
edgeTtsVoice: 'en-US-AriaNeural',
|
|
47
|
+
|
|
48
|
+
// XTTS (Local GPU)
|
|
49
|
+
localTtsUrl: '', // e.g. http://localhost:8020
|
|
50
|
+
|
|
51
|
+
// STT
|
|
52
|
+
sttProvider: 'none', // 'none', 'openai', 'groq', 'faster-whisper'
|
|
53
|
+
openaiSttModel: 'whisper-1',
|
|
54
|
+
groqApiKey: '',
|
|
55
|
+
groqSttModel: 'whisper-large-v3-turbo',
|
|
56
|
+
fasterWhisperUrl: '', // e.g. http://localhost:8000
|
|
57
|
+
|
|
58
|
+
// Real-time Voice
|
|
59
|
+
realtimeVoiceEnabled: false,
|
|
60
|
+
realtimeVoice: 'marin',
|
|
61
|
+
realtimeModel: 'gpt-realtime',
|
|
62
|
+
|
|
63
|
+
// Voice Mode
|
|
64
|
+
voiceMode: 'push-to-talk', // 'push-to-talk', 'live-voice', 'agent-voice'
|
|
65
|
+
agentVoiceTtsEngine: 'openai', // 'openai', 'edge'
|
|
66
|
+
agentVoiceTtsVoice: 'alloy',
|
|
67
|
+
voiceModel: '', // Model override for voice sessions (empty = use gateway default)
|
|
68
|
+
vadSilenceDurationMs: 400, // How long silence before VAD triggers (200-1500ms)
|
|
69
|
+
|
|
70
|
+
// Security
|
|
71
|
+
encryptHistory: false,
|
|
72
|
+
|
|
73
|
+
// UI
|
|
74
|
+
wakeWord: 'uplink',
|
|
75
|
+
|
|
76
|
+
// Server
|
|
77
|
+
port: 3456,
|
|
78
|
+
allowedOrigins: ['http://localhost:3456'],
|
|
79
|
+
|
|
80
|
+
// Premium
|
|
81
|
+
licenseKey: '',
|
|
82
|
+
|
|
83
|
+
// State
|
|
84
|
+
onboardingComplete: false,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
let cachedConfig = null;
|
|
88
|
+
let cacheTimestamp = 0;
|
|
89
|
+
const CACHE_TTL_MS = 5000; // Cache for 5 seconds to detect external changes
|
|
90
|
+
|
|
91
|
+
// Cached auto-discovery result (only runs once at startup)
|
|
92
|
+
let discoveryResult = null;
|
|
93
|
+
let discoveryAttempted = false;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Load configuration from file, with env fallbacks
|
|
97
|
+
*/
|
|
98
|
+
export async function loadConfig() {
|
|
99
|
+
// Check if cache is still fresh
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (cachedConfig && (now - cacheTimestamp) < CACHE_TTL_MS) {
|
|
102
|
+
return cachedConfig;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let fileConfig = {};
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const data = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
109
|
+
fileConfig = JSON.parse(data);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// File doesn't exist or is invalid - that's fine, use defaults
|
|
112
|
+
if (err.code !== 'ENOENT') {
|
|
113
|
+
log.warn('Failed to parse config.json:', err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Merge with defaults and env overrides
|
|
118
|
+
cachedConfig = {
|
|
119
|
+
...DEFAULTS,
|
|
120
|
+
...fileConfig,
|
|
121
|
+
// Env vars take precedence if set (for backwards compat)
|
|
122
|
+
...(process.env.GATEWAY_URL && { gatewayUrl: process.env.GATEWAY_URL }),
|
|
123
|
+
...(process.env.GATEWAY_TOKEN && { gatewayToken: process.env.GATEWAY_TOKEN }),
|
|
124
|
+
...(process.env.SESSION_USER && { sessionUser: process.env.SESSION_USER }),
|
|
125
|
+
...(process.env.ASSISTANT_NAME && { assistantName: process.env.ASSISTANT_NAME }),
|
|
126
|
+
...(process.env.ELEVENLABS_API_KEY && { elevenLabsApiKey: process.env.ELEVENLABS_API_KEY }),
|
|
127
|
+
...(process.env.ELEVENLABS_VOICE_ID && { elevenLabsVoiceId: process.env.ELEVENLABS_VOICE_ID }),
|
|
128
|
+
...(process.env.TTS_VOICE_NAME && { ttsVoiceName: process.env.TTS_VOICE_NAME }),
|
|
129
|
+
...(process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.startsWith('sk-') && { openaiApiKey: process.env.OPENAI_API_KEY }),
|
|
130
|
+
...(process.env.OPENAI_TTS_VOICE && { openaiTtsVoice: process.env.OPENAI_TTS_VOICE }),
|
|
131
|
+
...(process.env.OPENAI_TTS_MODEL && { openaiTtsModel: process.env.OPENAI_TTS_MODEL }),
|
|
132
|
+
...(process.env.EDGE_TTS_VOICE && { edgeTtsVoice: process.env.EDGE_TTS_VOICE }),
|
|
133
|
+
...(process.env.LOCAL_TTS_URL && { localTtsUrl: process.env.LOCAL_TTS_URL }),
|
|
134
|
+
// STT env overrides
|
|
135
|
+
...(process.env.STT_PROVIDER && { sttProvider: process.env.STT_PROVIDER }),
|
|
136
|
+
...(process.env.GROQ_API_KEY && { groqApiKey: process.env.GROQ_API_KEY }),
|
|
137
|
+
...(process.env.GROQ_STT_MODEL && { groqSttModel: process.env.GROQ_STT_MODEL }),
|
|
138
|
+
...(process.env.FASTER_WHISPER_URL && { fasterWhisperUrl: process.env.FASTER_WHISPER_URL }),
|
|
139
|
+
...(process.env.WAKE_WORD && { wakeWord: process.env.WAKE_WORD.toLowerCase() }),
|
|
140
|
+
...(process.env.PORT && { port: parseInt(process.env.PORT, 10) }),
|
|
141
|
+
...(process.env.ALLOWED_ORIGINS && {
|
|
142
|
+
allowedOrigins: process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Auto-discover OpenClaw gateway if not explicitly configured
|
|
147
|
+
// Priority: env vars > config.json > auto-discovery > defaults
|
|
148
|
+
const hasGatewayConfig = process.env.GATEWAY_URL || process.env.GATEWAY_TOKEN ||
|
|
149
|
+
fileConfig.gatewayUrl || fileConfig.gatewayToken;
|
|
150
|
+
|
|
151
|
+
if (!hasGatewayConfig && !discoveryAttempted) {
|
|
152
|
+
discoveryAttempted = true;
|
|
153
|
+
try {
|
|
154
|
+
discoveryResult = await discoverGateway();
|
|
155
|
+
if (discoveryResult) {
|
|
156
|
+
log.info(`Auto-discovered OpenClaw gateway: ${discoveryResult.url} (source: ${discoveryResult.source})`);
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
log.warn('Gateway auto-discovery failed:', err.message);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Apply discovered values if no explicit config exists
|
|
164
|
+
if (discoveryResult && !hasGatewayConfig) {
|
|
165
|
+
if (discoveryResult.url && cachedConfig.gatewayUrl === DEFAULTS.gatewayUrl) {
|
|
166
|
+
cachedConfig.gatewayUrl = discoveryResult.url;
|
|
167
|
+
}
|
|
168
|
+
if (discoveryResult.token && !cachedConfig.gatewayToken) {
|
|
169
|
+
cachedConfig.gatewayToken = discoveryResult.token;
|
|
170
|
+
}
|
|
171
|
+
// Mark onboarding as complete if we have both URL and token
|
|
172
|
+
if (discoveryResult.url && discoveryResult.token && discoveryResult.verified) {
|
|
173
|
+
cachedConfig.onboardingComplete = true;
|
|
174
|
+
cachedConfig._autoDiscovered = true; // Flag for UI to show discovery status
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Auto-detect TTS provider only if not explicitly set
|
|
179
|
+
// (undefined means not configured, 'none' means user explicitly disabled)
|
|
180
|
+
if (cachedConfig.ttsProvider === undefined) {
|
|
181
|
+
if (cachedConfig.elevenLabsApiKey) {
|
|
182
|
+
cachedConfig.ttsProvider = 'elevenlabs';
|
|
183
|
+
} else {
|
|
184
|
+
cachedConfig.ttsProvider = 'none';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Update cache timestamp
|
|
189
|
+
cacheTimestamp = Date.now();
|
|
190
|
+
|
|
191
|
+
return cachedConfig;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Save configuration to file (with file locking to prevent race conditions)
|
|
196
|
+
*/
|
|
197
|
+
export async function saveConfig(updates) {
|
|
198
|
+
// Acquire lock before read-modify-write
|
|
199
|
+
let release;
|
|
200
|
+
try {
|
|
201
|
+
// Create file if it doesn't exist (lockfile needs the file to exist)
|
|
202
|
+
try {
|
|
203
|
+
await fs.access(CONFIG_PATH);
|
|
204
|
+
} catch {
|
|
205
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2), { mode: 0o600 });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
release = await lockfile.lock(CONFIG_PATH, {
|
|
209
|
+
retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 }
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const current = await loadConfig();
|
|
213
|
+
|
|
214
|
+
// Merge updates
|
|
215
|
+
const newConfig = {
|
|
216
|
+
...current,
|
|
217
|
+
...updates,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Don't save sensitive env-provided values to file
|
|
221
|
+
const toSave = { ...newConfig };
|
|
222
|
+
|
|
223
|
+
// If the value came from env or auto-discovery, don't persist it to file
|
|
224
|
+
if (process.env.GATEWAY_TOKEN === toSave.gatewayToken) {
|
|
225
|
+
delete toSave.gatewayToken;
|
|
226
|
+
}
|
|
227
|
+
if (discoveryResult?.token === toSave.gatewayToken && !toSave.onboardingComplete) {
|
|
228
|
+
delete toSave.gatewayToken; // Don't duplicate OpenClaw's token into Uplink config
|
|
229
|
+
}
|
|
230
|
+
if (discoveryResult?.url === toSave.gatewayUrl && !toSave.onboardingComplete) {
|
|
231
|
+
delete toSave.gatewayUrl; // Don't duplicate OpenClaw's URL into Uplink config
|
|
232
|
+
}
|
|
233
|
+
if (process.env.ELEVENLABS_API_KEY === toSave.elevenLabsApiKey) {
|
|
234
|
+
delete toSave.elevenLabsApiKey;
|
|
235
|
+
}
|
|
236
|
+
if (process.env.OPENAI_API_KEY === toSave.openaiApiKey) {
|
|
237
|
+
delete toSave.openaiApiKey;
|
|
238
|
+
}
|
|
239
|
+
if (process.env.GROQ_API_KEY === toSave.groqApiKey) {
|
|
240
|
+
delete toSave.groqApiKey;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Remove internal flags before persisting
|
|
244
|
+
delete toSave._autoDiscovered;
|
|
245
|
+
|
|
246
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(toSave, null, 2), { mode: 0o600 });
|
|
247
|
+
|
|
248
|
+
// Clear cache to reload
|
|
249
|
+
cachedConfig = null;
|
|
250
|
+
cacheTimestamp = 0;
|
|
251
|
+
|
|
252
|
+
return await loadConfig();
|
|
253
|
+
} finally {
|
|
254
|
+
// Always release lock
|
|
255
|
+
if (release) {
|
|
256
|
+
await release();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get config for client (hides sensitive values)
|
|
263
|
+
*/
|
|
264
|
+
export async function getClientConfig() {
|
|
265
|
+
const config = await loadConfig();
|
|
266
|
+
const { detectLocalSTT } = await import('./stt/index.js');
|
|
267
|
+
const localSTT = await detectLocalSTT();
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
userName: config.userName,
|
|
271
|
+
assistantName: config.assistantName,
|
|
272
|
+
gatewayUrl: config.gatewayUrl,
|
|
273
|
+
hasGatewayToken: !!config.gatewayToken,
|
|
274
|
+
sessionUser: config.sessionUser,
|
|
275
|
+
ttsProvider: config.ttsProvider,
|
|
276
|
+
hasElevenLabsKey: !!config.elevenLabsApiKey,
|
|
277
|
+
elevenLabsVoiceId: config.elevenLabsVoiceId,
|
|
278
|
+
ttsVoiceName: config.ttsVoiceName,
|
|
279
|
+
edgeTtsAvailable: isEdgeTTSAvailable(),
|
|
280
|
+
encryptHistory: config.encryptHistory,
|
|
281
|
+
wakeWord: config.wakeWord,
|
|
282
|
+
onboardingComplete: config.onboardingComplete,
|
|
283
|
+
autoDiscovered: !!config._autoDiscovered, // True if gateway was found via OpenClaw config
|
|
284
|
+
// OpenAI TTS
|
|
285
|
+
openaiTtsVoice: config.openaiTtsVoice,
|
|
286
|
+
openaiTtsModel: config.openaiTtsModel,
|
|
287
|
+
hasOpenaiKey: !!(config.openaiApiKey || process.env.OPENAI_API_KEY),
|
|
288
|
+
// Edge TTS
|
|
289
|
+
edgeTtsVoice: config.edgeTtsVoice,
|
|
290
|
+
// XTTS
|
|
291
|
+
localTtsUrl: config.localTtsUrl,
|
|
292
|
+
// Piper
|
|
293
|
+
piperConfigured: !!(process.env.PIPER_MODEL),
|
|
294
|
+
// STT
|
|
295
|
+
sttProvider: config.sttProvider,
|
|
296
|
+
hasGroqKey: !!config.groqApiKey,
|
|
297
|
+
groqSttModel: config.groqSttModel,
|
|
298
|
+
openaiSttModel: config.openaiSttModel,
|
|
299
|
+
fasterWhisperUrl: config.fasterWhisperUrl,
|
|
300
|
+
fasterWhisperDetected: localSTT.fasterWhisperAvailable,
|
|
301
|
+
// Real-time Voice
|
|
302
|
+
realtimeVoiceEnabled: config.realtimeVoiceEnabled,
|
|
303
|
+
realtimeVoice: config.realtimeVoice,
|
|
304
|
+
realtimeModel: config.realtimeModel,
|
|
305
|
+
// Voice Mode
|
|
306
|
+
voiceMode: config.voiceMode,
|
|
307
|
+
voiceModel: config.voiceModel,
|
|
308
|
+
agentVoiceTtsEngine: config.agentVoiceTtsEngine,
|
|
309
|
+
agentVoiceTtsVoice: config.agentVoiceTtsVoice,
|
|
310
|
+
vadSilenceDurationMs: config.vadSilenceDurationMs,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check if onboarding is needed
|
|
316
|
+
*/
|
|
317
|
+
export async function needsOnboarding() {
|
|
318
|
+
const config = await loadConfig();
|
|
319
|
+
return !config.onboardingComplete || !config.gatewayToken;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Clear cached config (for testing or hot reload)
|
|
324
|
+
*/
|
|
325
|
+
export function clearConfigCache() {
|
|
326
|
+
cachedConfig = null;
|
|
327
|
+
cacheTimestamp = 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export default {
|
|
331
|
+
loadConfig,
|
|
332
|
+
saveConfig,
|
|
333
|
+
getClientConfig,
|
|
334
|
+
needsOnboarding,
|
|
335
|
+
clearConfigCache,
|
|
336
|
+
};
|
package/server/share.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Share Module - Public share links for conversations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { log } from './utils.js';
|
|
9
|
+
import { escapeHtml, escapeForJS } from '../utils/html-escape.js';
|
|
10
|
+
import { sanitizeShareId, parseNumericParam } from '../utils/id-sanitize.js';
|
|
11
|
+
import { sendSuccess, sendError } from '../utils/response.js';
|
|
12
|
+
import { errors } from '../utils/errors.js';
|
|
13
|
+
import { strictLimiter } from './middleware.js';
|
|
14
|
+
|
|
15
|
+
// Share configuration constants
|
|
16
|
+
const DEFAULT_EXPIRY_HOURS = 168; // 7 days
|
|
17
|
+
const MIN_EXPIRY_HOURS = 1;
|
|
18
|
+
const MAX_EXPIRY_HOURS = 8760; // 1 year
|
|
19
|
+
const MAX_MESSAGE_TEXT_LENGTH = 10000;
|
|
20
|
+
const MAX_MESSAGES_PER_SHARE = 500;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sanitize message content for safe storage and display
|
|
24
|
+
* Strips potential XSS vectors while preserving legitimate content
|
|
25
|
+
* @param {string} text - Raw message text
|
|
26
|
+
* @returns {string} Sanitized text
|
|
27
|
+
*/
|
|
28
|
+
function sanitizeMessageText(text) {
|
|
29
|
+
if (!text || typeof text !== 'string') {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Truncate to max length
|
|
34
|
+
let sanitized = text.substring(0, MAX_MESSAGE_TEXT_LENGTH);
|
|
35
|
+
|
|
36
|
+
// Remove null bytes and control characters (except newlines/tabs)
|
|
37
|
+
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
38
|
+
|
|
39
|
+
// Note: HTML escaping happens at render time in the client
|
|
40
|
+
// We store the raw text but ensure it doesn't contain script injection vectors
|
|
41
|
+
|
|
42
|
+
return sanitized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate and sanitize message type
|
|
47
|
+
* @param {string} type - Message type
|
|
48
|
+
* @returns {string} Validated type
|
|
49
|
+
*/
|
|
50
|
+
function sanitizeMessageType(type) {
|
|
51
|
+
const validTypes = ['user', 'assistant', 'system'];
|
|
52
|
+
if (typeof type === 'string' && validTypes.includes(type)) {
|
|
53
|
+
return type;
|
|
54
|
+
}
|
|
55
|
+
return 'user'; // Default to user if invalid
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Setup share routes
|
|
60
|
+
* @param {Express} app - Express app instance
|
|
61
|
+
* @param {string} sharesDir - Directory to store shares
|
|
62
|
+
*/
|
|
63
|
+
export function setupShareRoutes(app, sharesDir) {
|
|
64
|
+
// Ensure shares directory exists
|
|
65
|
+
fs.mkdir(sharesDir, { recursive: true }).catch(() => {});
|
|
66
|
+
|
|
67
|
+
// Create a shareable link for a conversation
|
|
68
|
+
app.post('/api/share/create', strictLimiter, async (req, res) => {
|
|
69
|
+
try {
|
|
70
|
+
const { title, messages, expiresInHours: rawExpiry } = req.body;
|
|
71
|
+
|
|
72
|
+
// Validate expiresInHours as number
|
|
73
|
+
const expiryResult = parseNumericParam(rawExpiry, {
|
|
74
|
+
min: MIN_EXPIRY_HOURS,
|
|
75
|
+
max: MAX_EXPIRY_HOURS,
|
|
76
|
+
defaultValue: DEFAULT_EXPIRY_HOURS,
|
|
77
|
+
paramName: 'expiresInHours'
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!expiryResult.valid) {
|
|
81
|
+
return errors.badRequest(res, expiryResult.error);
|
|
82
|
+
}
|
|
83
|
+
const expiresInHours = expiryResult.value;
|
|
84
|
+
|
|
85
|
+
// Validate messages array
|
|
86
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
87
|
+
return errors.badRequest(res, 'messages array required');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (messages.length > MAX_MESSAGES_PER_SHARE) {
|
|
91
|
+
return errors.badRequest(res, `Too many messages (max ${MAX_MESSAGES_PER_SHARE})`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const shareId = randomUUID().replace(/-/g, '').substring(0, 12);
|
|
95
|
+
const shareFile = path.join(sharesDir, `${shareId}.json`);
|
|
96
|
+
|
|
97
|
+
// Sanitize title
|
|
98
|
+
const safeTitle = typeof title === 'string'
|
|
99
|
+
? title.substring(0, 200).replace(/[\x00-\x1F\x7F]/g, '')
|
|
100
|
+
: 'Shared Conversation';
|
|
101
|
+
|
|
102
|
+
// Sanitize messages with server-side validation
|
|
103
|
+
const sanitizedMessages = messages.map(m => ({
|
|
104
|
+
type: sanitizeMessageType(m.type),
|
|
105
|
+
text: sanitizeMessageText(m.text),
|
|
106
|
+
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now()
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const shareData = {
|
|
110
|
+
id: shareId,
|
|
111
|
+
title: safeTitle,
|
|
112
|
+
messages: sanitizedMessages,
|
|
113
|
+
createdAt: Date.now(),
|
|
114
|
+
expiresAt: Date.now() + (expiresInHours * 60 * 60 * 1000),
|
|
115
|
+
viewCount: 0
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Atomic write: write to temp file first, then rename
|
|
119
|
+
const tempShareFile = path.join(sharesDir, `.tmp-${shareId}-${randomUUID()}.json`);
|
|
120
|
+
await fs.writeFile(tempShareFile, JSON.stringify(shareData, null, 2));
|
|
121
|
+
await fs.rename(tempShareFile, shareFile);
|
|
122
|
+
|
|
123
|
+
log('info', `[Share] Created share: ${shareId}`);
|
|
124
|
+
|
|
125
|
+
return sendSuccess(res, {
|
|
126
|
+
shareId,
|
|
127
|
+
shareUrl: `/share/${shareId}`,
|
|
128
|
+
expiresAt: shareData.expiresAt
|
|
129
|
+
});
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log('error', '[Share] Create error:', e.message);
|
|
132
|
+
return errors.serverError(res, 'Failed to create share');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Get shared conversation
|
|
137
|
+
app.get('/api/share/:shareId', async (req, res) => {
|
|
138
|
+
try {
|
|
139
|
+
const { shareId } = req.params;
|
|
140
|
+
|
|
141
|
+
// Validate shareId using centralized utility
|
|
142
|
+
const shareIdResult = sanitizeShareId(shareId);
|
|
143
|
+
if (!shareIdResult.valid) {
|
|
144
|
+
return errors.badRequest(res, shareIdResult.error);
|
|
145
|
+
}
|
|
146
|
+
const safeShareId = shareIdResult.sanitized;
|
|
147
|
+
|
|
148
|
+
const shareFile = path.join(sharesDir, `${safeShareId}.json`);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const data = await fs.readFile(shareFile, 'utf8');
|
|
152
|
+
const shareData = JSON.parse(data);
|
|
153
|
+
|
|
154
|
+
if (Date.now() > shareData.expiresAt) {
|
|
155
|
+
await fs.unlink(shareFile).catch(() => {});
|
|
156
|
+
return errors.notFound(res, 'Share link has expired');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
shareData.viewCount++;
|
|
160
|
+
await fs.writeFile(shareFile, JSON.stringify(shareData, null, 2));
|
|
161
|
+
|
|
162
|
+
return sendSuccess(res, shareData);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return errors.notFound(res, 'Share not found');
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
log('error', '[Share] Get error:', e.message);
|
|
168
|
+
return errors.serverError(res, 'Failed to get share');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Delete a share
|
|
173
|
+
app.delete('/api/share/:shareId', strictLimiter, async (req, res) => {
|
|
174
|
+
try {
|
|
175
|
+
const { shareId } = req.params;
|
|
176
|
+
|
|
177
|
+
// Validate shareId using centralized utility
|
|
178
|
+
const shareIdResult = sanitizeShareId(shareId);
|
|
179
|
+
if (!shareIdResult.valid) {
|
|
180
|
+
return errors.badRequest(res, shareIdResult.error);
|
|
181
|
+
}
|
|
182
|
+
const safeShareId = shareIdResult.sanitized;
|
|
183
|
+
|
|
184
|
+
const shareFile = path.join(sharesDir, `${safeShareId}.json`);
|
|
185
|
+
|
|
186
|
+
await fs.unlink(shareFile);
|
|
187
|
+
return sendSuccess(res);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return errors.notFound(res, 'Share not found');
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Serve shared conversation page
|
|
194
|
+
app.get('/share/:shareId', async (req, res) => {
|
|
195
|
+
res.send(getSharePageHTML(req.params.shareId));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Generate HTML for share page
|
|
201
|
+
*/
|
|
202
|
+
function getSharePageHTML(shareId) {
|
|
203
|
+
// Sanitize shareId to prevent XSS - only allow alphanumeric
|
|
204
|
+
const safeShareId = escapeForJS(shareId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 12));
|
|
205
|
+
|
|
206
|
+
return `
|
|
207
|
+
<!DOCTYPE html>
|
|
208
|
+
<html lang="en">
|
|
209
|
+
<head>
|
|
210
|
+
<meta charset="UTF-8">
|
|
211
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
212
|
+
<title>Shared Conversation - Uplink</title>
|
|
213
|
+
<style>
|
|
214
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
215
|
+
body {
|
|
216
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
217
|
+
background: #0f0f1a;
|
|
218
|
+
color: #fff;
|
|
219
|
+
min-height: 100vh;
|
|
220
|
+
padding: 20px;
|
|
221
|
+
}
|
|
222
|
+
.container { max-width: 700px; margin: 0 auto; }
|
|
223
|
+
.header {
|
|
224
|
+
text-align: center;
|
|
225
|
+
padding: 20px 0 30px;
|
|
226
|
+
border-bottom: 1px solid #333;
|
|
227
|
+
margin-bottom: 20px;
|
|
228
|
+
}
|
|
229
|
+
.logo { font-size: 2em; margin-bottom: 8px; }
|
|
230
|
+
.title { font-size: 1.2em; color: #888; }
|
|
231
|
+
.messages { display: flex; flex-direction: column; gap: 12px; }
|
|
232
|
+
.message {
|
|
233
|
+
padding: 12px 16px;
|
|
234
|
+
border-radius: 12px;
|
|
235
|
+
max-width: 85%;
|
|
236
|
+
}
|
|
237
|
+
.message.user {
|
|
238
|
+
background: #6366f1;
|
|
239
|
+
align-self: flex-end;
|
|
240
|
+
border-bottom-right-radius: 4px;
|
|
241
|
+
}
|
|
242
|
+
.message.assistant {
|
|
243
|
+
background: #1e1e2e;
|
|
244
|
+
align-self: flex-start;
|
|
245
|
+
border-bottom-left-radius: 4px;
|
|
246
|
+
}
|
|
247
|
+
.footer {
|
|
248
|
+
text-align: center;
|
|
249
|
+
margin-top: 30px;
|
|
250
|
+
padding-top: 20px;
|
|
251
|
+
border-top: 1px solid #333;
|
|
252
|
+
color: #666;
|
|
253
|
+
font-size: 0.9em;
|
|
254
|
+
}
|
|
255
|
+
.footer a { color: #6366f1; text-decoration: none; }
|
|
256
|
+
.error { text-align: center; padding: 40px; color: #ef4444; }
|
|
257
|
+
.loading { text-align: center; padding: 40px; color: #888; }
|
|
258
|
+
</style>
|
|
259
|
+
</head>
|
|
260
|
+
<body>
|
|
261
|
+
<div class="container">
|
|
262
|
+
<div class="header">
|
|
263
|
+
<div class="logo">🛰️ Uplink</div>
|
|
264
|
+
<div class="title" id="shareTitle">Loading...</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="messages" id="messages">
|
|
267
|
+
<div class="loading">Loading conversation...</div>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="footer">
|
|
270
|
+
Shared via <a href="/">Uplink</a>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
<script>
|
|
274
|
+
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
275
|
+
const shareId = '${safeShareId}';
|
|
276
|
+
fetch('/api/share/' + shareId)
|
|
277
|
+
.then(r => r.json())
|
|
278
|
+
.then(data => {
|
|
279
|
+
if (!data.ok || data.error) {
|
|
280
|
+
document.getElementById('shareTitle').textContent = 'Error';
|
|
281
|
+
document.getElementById('messages').innerHTML = '<div class="error">' + esc(data.error || 'Unknown error') + '</div>';
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
document.getElementById('shareTitle').textContent = data.title;
|
|
285
|
+
document.getElementById('messages').innerHTML = data.messages
|
|
286
|
+
.filter(m => m.type !== 'system')
|
|
287
|
+
.map(m => '<div class="message ' + m.type + '">' + escapeHtml(m.text) + '</div>')
|
|
288
|
+
.join('');
|
|
289
|
+
})
|
|
290
|
+
.catch(e => {
|
|
291
|
+
document.getElementById('shareTitle').textContent = 'Error';
|
|
292
|
+
document.getElementById('messages').innerHTML = '<div class="error">Failed to load</div>';
|
|
293
|
+
});
|
|
294
|
+
function escapeHtml(text) {
|
|
295
|
+
const div = document.createElement('div');
|
|
296
|
+
div.textContent = text;
|
|
297
|
+
return div.innerHTML.replace(/\\n/g, '<br>');
|
|
298
|
+
}
|
|
299
|
+
</script>
|
|
300
|
+
</body>
|
|
301
|
+
</html>
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export default { setupShareRoutes };
|