@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,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Routes - Configuration and gateway validation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
|
|
6
|
+
import { isEdgeTTSAvailable, isPiperConfigured, isLocalTTSConfigured, listEdgeTTSVoices } from '../tts.js';
|
|
7
|
+
import { GATEWAY_VALIDATION_TIMEOUT_MS } from '../config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Setup config routes
|
|
11
|
+
* @param {Express} app - Express app instance
|
|
12
|
+
* @param {Object} context - Request context
|
|
13
|
+
*/
|
|
14
|
+
export function setupConfigRoutes(app, context) {
|
|
15
|
+
const {
|
|
16
|
+
getClientConfig,
|
|
17
|
+
saveConfig,
|
|
18
|
+
loadConfig,
|
|
19
|
+
needsOnboarding,
|
|
20
|
+
strictLimiter,
|
|
21
|
+
isPrivateIP,
|
|
22
|
+
log,
|
|
23
|
+
} = context;
|
|
24
|
+
|
|
25
|
+
// ===========================================
|
|
26
|
+
// Gateway Validation
|
|
27
|
+
// ===========================================
|
|
28
|
+
|
|
29
|
+
app.post('/api/gateway/validate', strictLimiter, async (req, res) => {
|
|
30
|
+
let { url } = req.body;
|
|
31
|
+
|
|
32
|
+
if (!url) {
|
|
33
|
+
return badRequest(res, 'No URL provided', ErrorCodes.MISSING_FIELD);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
url = url.replace(/^(wss?|https?)\/*:+\/*/, '$1://');
|
|
37
|
+
url = url.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
|
|
38
|
+
|
|
39
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
40
|
+
url = 'http://' + url;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let parsedUrl;
|
|
44
|
+
try {
|
|
45
|
+
parsedUrl = new URL(url);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return badRequest(res, 'Invalid URL format', ErrorCodes.INVALID_FORMAT);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
51
|
+
return badRequest(res, 'Only HTTP/HTTPS URLs are allowed', ErrorCodes.INVALID_FORMAT);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hostname = parsedUrl.hostname.replace(/^\[|\]$/g, ''); // Strip IPv6 brackets
|
|
55
|
+
|
|
56
|
+
// Reject link-local addresses (169.254.x.x, fe80::) - never valid gateway targets
|
|
57
|
+
const isLinkLocal = /^169\.254\./.test(hostname) || /^fe80:/i.test(hostname);
|
|
58
|
+
if (isLinkLocal) {
|
|
59
|
+
return badRequest(res, 'Link-local addresses are not allowed as gateway URLs.', ErrorCodes.FORBIDDEN);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isPrivateIP(hostname)) {
|
|
63
|
+
// Localhost is always allowed — Uplink is local-first, gateway is typically on localhost
|
|
64
|
+
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
65
|
+
if (!isLocalhost) {
|
|
66
|
+
// Block other private IPs (10.x, 192.168.x, etc.) unless explicitly allowed
|
|
67
|
+
const allowPrivate = process.env.ALLOW_PRIVATE_GATEWAY === 'true';
|
|
68
|
+
if (!allowPrivate) {
|
|
69
|
+
return badRequest(res, 'Private/internal URLs are not allowed. Set ALLOW_PRIVATE_GATEWAY=true to allow LAN addresses.', ErrorCodes.FORBIDDEN);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timeout = setTimeout(() => controller.abort(), GATEWAY_VALIDATION_TIMEOUT_MS);
|
|
77
|
+
|
|
78
|
+
// Try multiple endpoints - OpenClaw serves HTML on root, so just check reachability
|
|
79
|
+
const baseUrl = url.replace(/\/$/, '');
|
|
80
|
+
let response;
|
|
81
|
+
let lastError;
|
|
82
|
+
|
|
83
|
+
for (const path of ['', '/health', '/v1/models']) {
|
|
84
|
+
try {
|
|
85
|
+
response = await fetch(`${baseUrl}${path}`, {
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
method: 'GET'
|
|
88
|
+
});
|
|
89
|
+
if (response.ok) break;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
lastError = e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clearTimeout(timeout);
|
|
96
|
+
|
|
97
|
+
if (response?.ok) {
|
|
98
|
+
res.json({ valid: true, status: response.status });
|
|
99
|
+
} else {
|
|
100
|
+
res.json({ valid: false, error: lastError?.message || `Gateway returned ${response?.status || 'no response'}` });
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error.code === 'ECONNREFUSED' || error.cause?.code === 'ECONNREFUSED') {
|
|
104
|
+
return res.json({ valid: false, error: 'Gateway not responding. Is it running? Try: openclaw gateway start' });
|
|
105
|
+
} else if (error.name === 'AbortError') {
|
|
106
|
+
return res.json({ valid: false, error: 'Connection timed out. Check firewall settings or try a different URL.' });
|
|
107
|
+
}
|
|
108
|
+
return res.json({
|
|
109
|
+
valid: false,
|
|
110
|
+
error: process.env.NODE_ENV === 'production' ? 'Could not connect to gateway.' : error.message
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ===========================================
|
|
116
|
+
// Configuration API
|
|
117
|
+
// ===========================================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get client-safe configuration
|
|
121
|
+
*/
|
|
122
|
+
app.get('/api/config', async (req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
const clientConfig = await getClientConfig();
|
|
125
|
+
res.json(clientConfig);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
log('error', '[Config] Get error:', error.message);
|
|
128
|
+
internalError(res, 'Failed to load configuration', ErrorCodes.CONFIG_ERROR);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if onboarding is needed
|
|
134
|
+
*/
|
|
135
|
+
app.get('/api/config/needs-onboarding', async (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
const needs = await needsOnboarding();
|
|
138
|
+
res.json({ needsOnboarding: needs });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
log('error', '[Config] Onboarding check error:', error.message);
|
|
141
|
+
internalError(res, 'Failed to check onboarding status', ErrorCodes.CONFIG_ERROR);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Update configuration (used by onboarding wizard)
|
|
147
|
+
*/
|
|
148
|
+
app.post('/api/config', strictLimiter, async (req, res) => {
|
|
149
|
+
try {
|
|
150
|
+
// Only allow known config keys to prevent config pollution
|
|
151
|
+
const ALLOWED_CONFIG_KEYS = new Set([
|
|
152
|
+
'userName', 'assistantName', 'gatewayUrl', 'gatewayToken',
|
|
153
|
+
'sessionUser', 'ttsProvider', 'elevenLabsApiKey', 'elevenLabsVoiceId',
|
|
154
|
+
'ttsVoiceName', 'edgeTtsVoice', 'localTtsUrl', 'encryptHistory',
|
|
155
|
+
'wakeWord', 'onboardingComplete', 'openaiApiKey', 'openaiTtsVoice',
|
|
156
|
+
'openaiTtsModel', 'sttProvider', 'groqApiKey', 'groqSttModel',
|
|
157
|
+
'fasterWhisperUrl', 'openaiSttModel', 'port', 'allowedOrigins',
|
|
158
|
+
'watchdogEnabled', 'networkAccess',
|
|
159
|
+
'realtimeVoiceEnabled', 'realtimeVoice', 'realtimeModel',
|
|
160
|
+
'voiceMode', 'voiceModel', 'agentVoiceTtsEngine', 'agentVoiceTtsVoice',
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const updates = {};
|
|
164
|
+
for (const [key, value] of Object.entries(req.body)) {
|
|
165
|
+
if (ALLOWED_CONFIG_KEYS.has(key)) {
|
|
166
|
+
updates[key] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (Object.keys(updates).length === 0) {
|
|
171
|
+
return badRequest(res, 'No valid config keys provided', ErrorCodes.VALIDATION_ERROR);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate required fields for onboarding
|
|
175
|
+
if (updates.onboardingComplete) {
|
|
176
|
+
const currentConfig = await loadConfig();
|
|
177
|
+
if (!updates.gatewayToken && !process.env.GATEWAY_TOKEN && !currentConfig.gatewayToken) {
|
|
178
|
+
return badRequest(res, 'Gateway token is required', ErrorCodes.MISSING_FIELD);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const newConfig = await saveConfig(updates);
|
|
183
|
+
const clientConfig = await getClientConfig();
|
|
184
|
+
|
|
185
|
+
log('info', '[Config] Configuration updated');
|
|
186
|
+
res.json({ ok: true, config: clientConfig });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
log('error', '[Config] Update error:', error.message);
|
|
189
|
+
internalError(res, 'Failed to save configuration', ErrorCodes.CONFIG_ERROR);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ===========================================
|
|
194
|
+
// Server Settings
|
|
195
|
+
// ===========================================
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get server status (watchdog, network binding)
|
|
199
|
+
*/
|
|
200
|
+
app.get('/api/config/server', async (req, res) => {
|
|
201
|
+
try {
|
|
202
|
+
const config = await loadConfig();
|
|
203
|
+
const isNetworkAccess = process.env.UPLINK_HOST === '0.0.0.0' || config.networkAccess === true;
|
|
204
|
+
|
|
205
|
+
// Check if watchdog is running
|
|
206
|
+
let watchdogRunning = false;
|
|
207
|
+
try {
|
|
208
|
+
const { readFileSync } = await import('fs');
|
|
209
|
+
const { join } = await import('path');
|
|
210
|
+
const stateFile = join(process.cwd(), '.uplink-watchdog.json');
|
|
211
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
212
|
+
watchdogRunning = state.status === 'running' && state.watchdogPid != null;
|
|
213
|
+
} catch {
|
|
214
|
+
// No watchdog state file
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
res.json({
|
|
218
|
+
watchdogEnabled: config.watchdogEnabled !== false, // default true
|
|
219
|
+
watchdogRunning,
|
|
220
|
+
networkAccess: isNetworkAccess,
|
|
221
|
+
bindAddress: process.env.UPLINK_HOST || (isNetworkAccess ? '0.0.0.0' : '127.0.0.1'),
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
log('error', '[Config] Server status error:', error.message);
|
|
225
|
+
internalError(res, 'Failed to get server status', ErrorCodes.CONFIG_ERROR);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Update server settings (requires restart to take effect)
|
|
231
|
+
*/
|
|
232
|
+
app.post('/api/config/server', strictLimiter, async (req, res) => {
|
|
233
|
+
try {
|
|
234
|
+
const updates = {};
|
|
235
|
+
|
|
236
|
+
if (typeof req.body.watchdogEnabled === 'boolean') {
|
|
237
|
+
updates.watchdogEnabled = req.body.watchdogEnabled;
|
|
238
|
+
}
|
|
239
|
+
if (typeof req.body.networkAccess === 'boolean') {
|
|
240
|
+
updates.networkAccess = req.body.networkAccess;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (Object.keys(updates).length === 0) {
|
|
244
|
+
return badRequest(res, 'No valid settings provided', ErrorCodes.VALIDATION_ERROR);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await saveConfig(updates);
|
|
248
|
+
log('info', `[Config] Server settings updated: ${JSON.stringify(updates)}`);
|
|
249
|
+
res.json({ ok: true, restartRequired: true });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
log('error', '[Config] Server settings error:', error.message);
|
|
252
|
+
internalError(res, 'Failed to save server settings', ErrorCodes.CONFIG_ERROR);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Restart the server process
|
|
258
|
+
*/
|
|
259
|
+
app.post('/api/config/server/restart', strictLimiter, async (req, res) => {
|
|
260
|
+
try {
|
|
261
|
+
log('info', '[Config] Server restart requested via settings UI');
|
|
262
|
+
res.json({ ok: true, message: 'Restarting...' });
|
|
263
|
+
|
|
264
|
+
// Give time for response to send, then exit
|
|
265
|
+
// Watchdog (if running) will auto-restart the process
|
|
266
|
+
setTimeout(() => {
|
|
267
|
+
process.exit(0);
|
|
268
|
+
}, 500);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
log('error', '[Config] Restart error:', error.message);
|
|
271
|
+
internalError(res, 'Failed to restart server', ErrorCodes.CONFIG_ERROR);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ===========================================
|
|
276
|
+
// API Keys Management
|
|
277
|
+
// ===========================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Save ElevenLabs API key (validates first)
|
|
281
|
+
*/
|
|
282
|
+
app.post('/api/config/elevenlabs-key', strictLimiter, async (req, res) => {
|
|
283
|
+
const { apiKey } = req.body;
|
|
284
|
+
|
|
285
|
+
if (!apiKey) {
|
|
286
|
+
return badRequest(res, 'API key is required', ErrorCodes.MISSING_FIELD);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Validate the key by fetching user info
|
|
290
|
+
try {
|
|
291
|
+
const response = await fetch('https://api.elevenlabs.io/v1/user', {
|
|
292
|
+
headers: { 'xi-api-key': apiKey }
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
return res.json({ valid: false, error: 'Invalid API key' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const userData = await response.json();
|
|
300
|
+
|
|
301
|
+
// Key is valid - save it
|
|
302
|
+
await saveConfig({ elevenLabsApiKey: apiKey, ttsProvider: 'elevenlabs' });
|
|
303
|
+
|
|
304
|
+
log('info', '[Config] ElevenLabs API key saved');
|
|
305
|
+
res.json({
|
|
306
|
+
valid: true,
|
|
307
|
+
subscription: userData.subscription?.tier || 'unknown'
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
log('error', '[Config] ElevenLabs validation error:', error.message);
|
|
311
|
+
res.json({ valid: false, error: 'Failed to validate key' });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Remove ElevenLabs API key
|
|
317
|
+
*/
|
|
318
|
+
app.delete('/api/config/elevenlabs-key', strictLimiter, async (req, res) => {
|
|
319
|
+
try {
|
|
320
|
+
await saveConfig({ elevenLabsApiKey: '', ttsProvider: 'none' });
|
|
321
|
+
log('info', '[Config] ElevenLabs API key removed');
|
|
322
|
+
res.json({ ok: true });
|
|
323
|
+
} catch (error) {
|
|
324
|
+
log('error', '[Config] Failed to remove key:', error.message);
|
|
325
|
+
internalError(res, 'Failed to remove key', ErrorCodes.CONFIG_ERROR);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get available ElevenLabs voices
|
|
331
|
+
*/
|
|
332
|
+
app.get('/api/config/elevenlabs-voices', async (req, res) => {
|
|
333
|
+
try {
|
|
334
|
+
const config = await loadConfig();
|
|
335
|
+
|
|
336
|
+
if (!config.elevenLabsApiKey) {
|
|
337
|
+
return res.json({ voices: [], error: 'No API key configured' });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const response = await fetch('https://api.elevenlabs.io/v1/voices', {
|
|
341
|
+
headers: { 'xi-api-key': config.elevenLabsApiKey }
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (!response.ok) {
|
|
345
|
+
return res.json({ voices: [], error: 'Failed to fetch voices' });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const data = await response.json();
|
|
349
|
+
const voices = data.voices?.map(v => ({
|
|
350
|
+
id: v.voice_id,
|
|
351
|
+
name: v.name,
|
|
352
|
+
category: v.category,
|
|
353
|
+
previewUrl: v.preview_url
|
|
354
|
+
})) || [];
|
|
355
|
+
|
|
356
|
+
res.json({ voices });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
log('error', '[Config] Failed to fetch voices:', error.message);
|
|
359
|
+
const errorMsg = process.env.NODE_ENV === 'production'
|
|
360
|
+
? 'Failed to fetch voices'
|
|
361
|
+
: error.message;
|
|
362
|
+
res.json({ voices: [], error: errorMsg });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Set ElevenLabs voice
|
|
368
|
+
*/
|
|
369
|
+
app.post('/api/config/elevenlabs-voice', strictLimiter, async (req, res) => {
|
|
370
|
+
const { voiceId, voiceName } = req.body;
|
|
371
|
+
|
|
372
|
+
if (!voiceId) {
|
|
373
|
+
return badRequest(res, 'Voice ID is required', ErrorCodes.MISSING_FIELD);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
await saveConfig({
|
|
378
|
+
elevenLabsVoiceId: voiceId,
|
|
379
|
+
ttsVoiceName: voiceName || 'Assistant'
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
log('info', `[Config] ElevenLabs voice set to ${voiceName || voiceId}`);
|
|
383
|
+
res.json({ ok: true });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
log('error', '[Config] Failed to set voice:', error.message);
|
|
386
|
+
internalError(res, 'Failed to save voice', ErrorCodes.CONFIG_ERROR);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ===========================================
|
|
391
|
+
// OpenAI API Key
|
|
392
|
+
// ===========================================
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Save OpenAI API key (validates first)
|
|
396
|
+
*/
|
|
397
|
+
app.post('/api/config/openai-key', strictLimiter, async (req, res) => {
|
|
398
|
+
const { apiKey } = req.body;
|
|
399
|
+
|
|
400
|
+
if (!apiKey) {
|
|
401
|
+
return badRequest(res, 'API key is required', ErrorCodes.MISSING_FIELD);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Validate the key by hitting the models endpoint
|
|
405
|
+
try {
|
|
406
|
+
const response = await fetch('https://api.openai.com/v1/models', {
|
|
407
|
+
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
if (!response.ok) {
|
|
411
|
+
return res.json({ valid: false, error: 'Invalid API key' });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Key is valid — save it
|
|
415
|
+
await saveConfig({ openaiApiKey: apiKey });
|
|
416
|
+
|
|
417
|
+
log('info', '[Config] OpenAI API key saved');
|
|
418
|
+
res.json({ valid: true });
|
|
419
|
+
} catch (error) {
|
|
420
|
+
log('error', '[Config] OpenAI key validation error:', error.message);
|
|
421
|
+
res.json({ valid: false, error: 'Failed to validate key' });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Remove OpenAI API key
|
|
427
|
+
*/
|
|
428
|
+
app.delete('/api/config/openai-key', strictLimiter, async (req, res) => {
|
|
429
|
+
try {
|
|
430
|
+
await saveConfig({ openaiApiKey: '' });
|
|
431
|
+
log('info', '[Config] OpenAI API key removed');
|
|
432
|
+
res.json({ ok: true });
|
|
433
|
+
} catch (error) {
|
|
434
|
+
log('error', '[Config] Failed to remove OpenAI key:', error.message);
|
|
435
|
+
internalError(res, 'Failed to remove key', ErrorCodes.CONFIG_ERROR);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ===========================================
|
|
440
|
+
// TTS Provider Status & Configuration
|
|
441
|
+
// ===========================================
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Get status of all TTS providers
|
|
445
|
+
*/
|
|
446
|
+
app.get('/api/config/tts-status', async (req, res) => {
|
|
447
|
+
try {
|
|
448
|
+
const config = await loadConfig();
|
|
449
|
+
|
|
450
|
+
res.json({
|
|
451
|
+
elevenlabs: {
|
|
452
|
+
available: true,
|
|
453
|
+
configured: !!config.elevenLabsApiKey,
|
|
454
|
+
needsKey: true,
|
|
455
|
+
label: 'ElevenLabs',
|
|
456
|
+
description: 'High-quality cloud TTS (requires API key)',
|
|
457
|
+
},
|
|
458
|
+
openai: {
|
|
459
|
+
available: true,
|
|
460
|
+
configured: !!(config.openaiApiKey || process.env.OPENAI_API_KEY),
|
|
461
|
+
needsKey: true,
|
|
462
|
+
hasKey: !!(config.openaiApiKey || process.env.OPENAI_API_KEY),
|
|
463
|
+
voice: config.openaiTtsVoice || 'nova',
|
|
464
|
+
model: config.openaiTtsModel || 'tts-1',
|
|
465
|
+
label: 'OpenAI TTS',
|
|
466
|
+
description: 'Cloud TTS (uses your OpenAI API key)',
|
|
467
|
+
},
|
|
468
|
+
edge: {
|
|
469
|
+
available: isEdgeTTSAvailable(),
|
|
470
|
+
configured: isEdgeTTSAvailable(),
|
|
471
|
+
installed: isEdgeTTSAvailable(),
|
|
472
|
+
voice: config.edgeTtsVoice || 'en-US-AriaNeural',
|
|
473
|
+
label: 'Edge TTS',
|
|
474
|
+
description: isEdgeTTSAvailable()
|
|
475
|
+
? 'Free Microsoft TTS (no API key needed)'
|
|
476
|
+
: 'Not installed — run: npm install node-edge-tts',
|
|
477
|
+
},
|
|
478
|
+
piper: {
|
|
479
|
+
available: isPiperConfigured(),
|
|
480
|
+
configured: isPiperConfigured(),
|
|
481
|
+
label: 'Piper',
|
|
482
|
+
description: isPiperConfigured()
|
|
483
|
+
? 'Fast local TTS (configured via environment)'
|
|
484
|
+
: 'Not configured — set PIPER_MODEL in .env',
|
|
485
|
+
},
|
|
486
|
+
local: {
|
|
487
|
+
available: isLocalTTSConfigured() || !!config.localTtsUrl,
|
|
488
|
+
configured: isLocalTTSConfigured() || !!config.localTtsUrl,
|
|
489
|
+
url: config.localTtsUrl || process.env.LOCAL_TTS_URL || '',
|
|
490
|
+
label: 'XTTS (Local GPU)',
|
|
491
|
+
description: (isLocalTTSConfigured() || config.localTtsUrl)
|
|
492
|
+
? 'GPU-accelerated local TTS'
|
|
493
|
+
: 'Not configured — enter server URL below',
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
} catch (error) {
|
|
497
|
+
log('error', '[Config] TTS status error:', error.message);
|
|
498
|
+
internalError(res, 'Failed to get TTS status', ErrorCodes.CONFIG_ERROR);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Get available Edge TTS voices
|
|
504
|
+
*/
|
|
505
|
+
app.get('/api/config/edge-voices', async (req, res) => {
|
|
506
|
+
try {
|
|
507
|
+
if (!isEdgeTTSAvailable()) {
|
|
508
|
+
return res.json({ voices: [], error: 'Edge TTS not installed' });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const voices = await listEdgeTTSVoices();
|
|
512
|
+
|
|
513
|
+
// Filter to English by default, but include all if requested
|
|
514
|
+
const lang = req.query.lang || 'en';
|
|
515
|
+
const filtered = lang === 'all'
|
|
516
|
+
? voices
|
|
517
|
+
: voices.filter(v => v.locale?.startsWith(lang));
|
|
518
|
+
|
|
519
|
+
res.json({ voices: filtered });
|
|
520
|
+
} catch (error) {
|
|
521
|
+
log('error', '[Config] Edge TTS voices error:', error.message);
|
|
522
|
+
const errorMsg = process.env.NODE_ENV === 'production'
|
|
523
|
+
? 'Failed to fetch Edge TTS voices'
|
|
524
|
+
: error.message;
|
|
525
|
+
res.json({ voices: [], error: errorMsg });
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Save OpenAI TTS settings (voice + model)
|
|
531
|
+
*/
|
|
532
|
+
app.post('/api/config/openai-tts', strictLimiter, async (req, res) => {
|
|
533
|
+
const { voice, model } = req.body;
|
|
534
|
+
|
|
535
|
+
const allowedVoices = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
|
536
|
+
const allowedModels = ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts'];
|
|
537
|
+
|
|
538
|
+
if (voice && !allowedVoices.includes(voice)) {
|
|
539
|
+
return badRequest(res, `Invalid voice. Options: ${allowedVoices.join(', ')}`, ErrorCodes.INVALID_FORMAT);
|
|
540
|
+
}
|
|
541
|
+
if (model && !allowedModels.includes(model)) {
|
|
542
|
+
return badRequest(res, `Invalid model. Options: ${allowedModels.join(', ')}`, ErrorCodes.INVALID_FORMAT);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const updates = {};
|
|
547
|
+
if (voice) updates.openaiTtsVoice = voice;
|
|
548
|
+
if (model) updates.openaiTtsModel = model;
|
|
549
|
+
|
|
550
|
+
await saveConfig(updates);
|
|
551
|
+
log('info', `[Config] OpenAI TTS updated: voice=${voice || '(unchanged)'}, model=${model || '(unchanged)'}`);
|
|
552
|
+
res.json({ ok: true });
|
|
553
|
+
} catch (error) {
|
|
554
|
+
log('error', '[Config] OpenAI TTS save error:', error.message);
|
|
555
|
+
internalError(res, 'Failed to save OpenAI TTS settings', ErrorCodes.CONFIG_ERROR);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Save Edge TTS voice selection
|
|
561
|
+
*/
|
|
562
|
+
app.post('/api/config/edge-voice', strictLimiter, async (req, res) => {
|
|
563
|
+
const { voice } = req.body;
|
|
564
|
+
|
|
565
|
+
if (!voice || typeof voice !== 'string') {
|
|
566
|
+
return badRequest(res, 'Voice name is required', ErrorCodes.MISSING_FIELD);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
await saveConfig({ edgeTtsVoice: voice });
|
|
571
|
+
log('info', `[Config] Edge TTS voice set to ${voice}`);
|
|
572
|
+
res.json({ ok: true });
|
|
573
|
+
} catch (error) {
|
|
574
|
+
log('error', '[Config] Edge TTS voice save error:', error.message);
|
|
575
|
+
internalError(res, 'Failed to save Edge TTS voice', ErrorCodes.CONFIG_ERROR);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Save XTTS server URL
|
|
581
|
+
*/
|
|
582
|
+
app.post('/api/config/local-tts', strictLimiter, async (req, res) => {
|
|
583
|
+
const { url } = req.body;
|
|
584
|
+
|
|
585
|
+
if (url !== undefined && url !== '') {
|
|
586
|
+
// Validate URL format
|
|
587
|
+
try {
|
|
588
|
+
new URL(url);
|
|
589
|
+
} catch {
|
|
590
|
+
return badRequest(res, 'Invalid URL format', ErrorCodes.INVALID_FORMAT);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
await saveConfig({ localTtsUrl: url || '' });
|
|
596
|
+
log('info', `[Config] XTTS URL set to ${url || '(cleared)'}`);
|
|
597
|
+
res.json({ ok: true });
|
|
598
|
+
} catch (error) {
|
|
599
|
+
log('error', '[Config] XTTS URL save error:', error.message);
|
|
600
|
+
internalError(res, 'Failed to save XTTS URL', ErrorCodes.CONFIG_ERROR);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
}
|