kingkont 0.6.2 → 0.7.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.
- package/index.html +590 -108
- package/main.js +219 -4
- package/package.json +3 -1
- package/preload.js +13 -0
- package/server.js +492 -45
- package/settings.html +299 -0
package/server.js
CHANGED
|
@@ -28,6 +28,11 @@ const KIE_BASE = 'https://api.kie.ai';
|
|
|
28
28
|
const ELEVEN_BASE = 'https://api.elevenlabs.io';
|
|
29
29
|
const ROOT = __dirname;
|
|
30
30
|
|
|
31
|
+
// Источник live-настроек (use*-флаги, chatium token). Подставляется через
|
|
32
|
+
// start({ getSettings }) из main.js. Если не передан — fallback на пустые
|
|
33
|
+
// настройки, и server.js работает как раньше через прямые провайдеры.
|
|
34
|
+
let getSettings = () => ({});
|
|
35
|
+
|
|
31
36
|
const MIME = {
|
|
32
37
|
'.html': 'text/html; charset=utf-8',
|
|
33
38
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -45,6 +50,142 @@ const MIME = {
|
|
|
45
50
|
};
|
|
46
51
|
|
|
47
52
|
// ---------- helpers ----------
|
|
53
|
+
// Логирует каждый исходящий запрос провайдеру. Видно в npm start output —
|
|
54
|
+
// это даёт чёткую картину «куда server.js реально пытается отправить данные».
|
|
55
|
+
function logProviderCall(method, provider, target, extra = '') {
|
|
56
|
+
const ts = new Date().toLocaleTimeString();
|
|
57
|
+
console.log(`[${ts}] → ${method.padEnd(4)} ${provider.padEnd(11)} ${target}${extra ? ' ' + extra : ''}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ===== Chatium proxy helpers ==============================================
|
|
61
|
+
// Архитектура: Chatium API асинхронный. POST /api/text|audio|video возвращает
|
|
62
|
+
// taskId сразу, callback @start/sdk через несколько секунд (или минут для
|
|
63
|
+
// видео) пишет результат в proxy_tasks. Здесь мы поллим /api/poll до готовности.
|
|
64
|
+
//
|
|
65
|
+
// Возвращаем результат клиенту в его «синхронном» формате — он не знает о
|
|
66
|
+
// chatium-async. Для long-running'а (видео) клиент сам так задумано работает
|
|
67
|
+
// через /api/generate + /api/poll, поэтому видео мы НЕ ждём здесь, а отдаём
|
|
68
|
+
// клиенту taskId с префиксом «chatium:» и эмулируем KIE poll.
|
|
69
|
+
|
|
70
|
+
const CHATIUM_PATHS = {
|
|
71
|
+
text: '/app/spaces/server/api/text',
|
|
72
|
+
audio: '/app/spaces/server/api/audio',
|
|
73
|
+
image: '/app/spaces/server/api/image',
|
|
74
|
+
video: '/app/spaces/server/api/video',
|
|
75
|
+
poll: '/app/spaces/server/api/poll',
|
|
76
|
+
balance: '/app/spaces/server/api/balance',
|
|
77
|
+
transactions: '/app/spaces/server/api/transactions',
|
|
78
|
+
uploadUrl: '/app/spaces/server/api/upload_url',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// CDN-домен Chatium для готовых ссылок после upload. Подтверждено в
|
|
82
|
+
// useImageEditorSave.ts (`https://sel.cdn-chatium.io/get/...`) и
|
|
83
|
+
// в chatium-sync.sh (`https://fs.chatium.ru/get/...`). Оба работают
|
|
84
|
+
// (alias'ы), используем fs.chatium.ru — он public-доступен и фигурирует
|
|
85
|
+
// в getThumbnailUrl-helper'е тоже.
|
|
86
|
+
const CHATIUM_CDN = 'https://fs.chatium.ru/get';
|
|
87
|
+
|
|
88
|
+
async function chatiumStart(s, kind, body) {
|
|
89
|
+
const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS[kind];
|
|
90
|
+
logProviderCall('POST', 'Chatium', url, summarizeBody(body));
|
|
91
|
+
const r = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'Authorization': `Bearer ${s.chatium.token}`,
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(body),
|
|
98
|
+
});
|
|
99
|
+
const text = await r.text();
|
|
100
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
101
|
+
if (!r.ok) {
|
|
102
|
+
const reason = data?.reason || data?.error || data?.raw || `HTTP ${r.status}`;
|
|
103
|
+
throw new Error(`Chatium /api/${kind} HTTP ${r.status}: ${String(reason).slice(0, 300)}`);
|
|
104
|
+
}
|
|
105
|
+
if (!data.taskId) throw new Error(`Chatium /api/${kind} вернул без taskId: ${text.slice(0, 200)}`);
|
|
106
|
+
return data.taskId;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function chatiumPoll(s, taskId) {
|
|
110
|
+
const url = `${s.chatium.base.replace(/\/$/, '')}${CHATIUM_PATHS.poll}?taskId=${encodeURIComponent(taskId)}`;
|
|
111
|
+
const r = await fetch(url, {
|
|
112
|
+
headers: { 'Authorization': `Bearer ${s.chatium.token}` },
|
|
113
|
+
});
|
|
114
|
+
const text = await r.text();
|
|
115
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
116
|
+
if (!r.ok) {
|
|
117
|
+
const reason = data?.reason || data?.error || data?.raw || `HTTP ${r.status}`;
|
|
118
|
+
throw new Error(`Chatium /api/poll HTTP ${r.status}: ${String(reason).slice(0, 300)}`);
|
|
119
|
+
}
|
|
120
|
+
return data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Поллит до готовности. Используется для коротких генераций (text, audio).
|
|
125
|
+
*
|
|
126
|
+
* Grace-period: для media (audio/image/video) cost приходит в @start через
|
|
127
|
+
* ОТДЕЛЬНЫЙ /onMediaBilled callback, иногда позже /onCompleted. Если task
|
|
128
|
+
* уже completed но creditsCharged ещё null — даём 5 сек чтобы billed
|
|
129
|
+
* подоспел и мы могли отдать клиенту корректную стоимость генерации.
|
|
130
|
+
*/
|
|
131
|
+
async function chatiumWait(s, taskId, timeoutMs = 120000, billedGraceMs = 5000) {
|
|
132
|
+
const deadline = Date.now() + timeoutMs;
|
|
133
|
+
let lastStatus = null;
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
const d = await chatiumPoll(s, taskId);
|
|
136
|
+
if (d.status !== lastStatus) {
|
|
137
|
+
console.log(`[chatium] task ${taskId.slice(0,8)}… status=${d.status}`);
|
|
138
|
+
lastStatus = d.status;
|
|
139
|
+
}
|
|
140
|
+
if (d.status === 'completed') {
|
|
141
|
+
if (typeof d.creditsCharged === 'number') return d;
|
|
142
|
+
// Status completed, ждём billed-callback (≤ billedGraceMs).
|
|
143
|
+
const graceUntil = Date.now() + billedGraceMs;
|
|
144
|
+
while (Date.now() < graceUntil) {
|
|
145
|
+
await new Promise(s2 => setTimeout(s2, 800));
|
|
146
|
+
const d2 = await chatiumPoll(s, taskId);
|
|
147
|
+
if (typeof d2.creditsCharged === 'number') {
|
|
148
|
+
console.log(`[chatium] task ${taskId.slice(0,8)}… billed=${d2.creditsCharged}`);
|
|
149
|
+
return d2;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.log(`[chatium] task ${taskId.slice(0,8)}… billed timeout, cost unknown`);
|
|
153
|
+
return d;
|
|
154
|
+
}
|
|
155
|
+
if (d.status === 'failed') throw new Error(d.error || 'chatium task failed');
|
|
156
|
+
await new Promise(s2 => setTimeout(s2, 1500));
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`chatium poll timeout after ${timeoutMs / 1000}s`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function summarizeBody(body) {
|
|
162
|
+
if (!body) return '';
|
|
163
|
+
const parts = [];
|
|
164
|
+
if (body.model) parts.push(`model=${body.model}`);
|
|
165
|
+
if (body.kind) parts.push(`kind=${body.kind}`);
|
|
166
|
+
if (typeof body.prompt === 'string') parts.push(`prompt=${body.prompt.length}ch`);
|
|
167
|
+
if (typeof body.text === 'string') parts.push(`text=${body.text.length}ch`);
|
|
168
|
+
if (Array.isArray(body.imageInputs)) parts.push(`imgs=${body.imageInputs.length}`);
|
|
169
|
+
return parts.join(' ');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Скачать URL и стримить в response (для аудио/видео из Chatium —
|
|
174
|
+
* клиент ждёт audio/mpeg, а Chatium отдаёт URL).
|
|
175
|
+
*/
|
|
176
|
+
async function streamUrlToResponse(url, res, contentType = 'audio/mpeg', extraHeaders = {}) {
|
|
177
|
+
const r = await fetch(url);
|
|
178
|
+
if (!r.ok) throw new Error(`download ${r.status} for ${url.slice(0, 100)}`);
|
|
179
|
+
res.writeHead(200, { 'Content-Type': contentType, ...extraHeaders });
|
|
180
|
+
const reader = r.body.getReader();
|
|
181
|
+
while (true) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done) break;
|
|
184
|
+
res.write(Buffer.from(value));
|
|
185
|
+
}
|
|
186
|
+
res.end();
|
|
187
|
+
}
|
|
188
|
+
|
|
48
189
|
function send(res, status, body, headers = {}) {
|
|
49
190
|
const isStr = typeof body === 'string';
|
|
50
191
|
res.writeHead(status, {
|
|
@@ -98,11 +239,89 @@ const VIDEO_MODELS = {
|
|
|
98
239
|
'seedance-2': 'bytedance/seedance-2',
|
|
99
240
|
};
|
|
100
241
|
|
|
242
|
+
// Маппинг для Chatium-маршрута. У Chatium свой набор моделей через
|
|
243
|
+
// @start/sdk — отличается от KIE: например, у KIE 'seedream/4.5-text-to-image',
|
|
244
|
+
// у Chatium 'bytedance/seedream-5-lite' (с префиксом vendor'а).
|
|
245
|
+
// Источники: spaces/api/execImageRunners.ts, spaces/api/execVideoRunners.ts.
|
|
246
|
+
const CHATIUM_IMAGE_MODELS = {
|
|
247
|
+
'nano-banana-2': 'nano-banana-2',
|
|
248
|
+
'nano-banana-pro': 'nano-banana-pro',
|
|
249
|
+
'grok': 'grok-imagine/text-to-image',
|
|
250
|
+
'grok-i2i': 'grok-imagine/image-to-image',
|
|
251
|
+
'seedream-5-lite': 'bytedance/seedream-5-lite',
|
|
252
|
+
};
|
|
253
|
+
const CHATIUM_VIDEO_MODELS = {
|
|
254
|
+
'seedance-2': 'bytedance/seedance-2',
|
|
255
|
+
'seedance-2-fast': 'bytedance/seedance-2-fast',
|
|
256
|
+
'veo-3.1': 'google/veo-3.1',
|
|
257
|
+
'kling-o1': 'kwaivgi/kling-o1',
|
|
258
|
+
'kling-3.0': 'kling-3.0/video',
|
|
259
|
+
'wan-2.7-i2v': 'wan/2-7-image-to-video',
|
|
260
|
+
'runway': 'runway',
|
|
261
|
+
'grok-i2v': 'grok-imagine/image-to-video',
|
|
262
|
+
'grok-t2v': 'grok-imagine/text-to-video',
|
|
263
|
+
};
|
|
264
|
+
|
|
101
265
|
// ---------- /api/generate ----------
|
|
102
266
|
async function handleGenerate(req, res) {
|
|
103
|
-
const
|
|
267
|
+
const body = await readJson(req);
|
|
268
|
+
const { kind, prompt, imageInputs, videoInputs, aspectRatio, resolution, duration, model: modelKey } = body;
|
|
104
269
|
if (!prompt || !prompt.trim()) return send(res, 400, { error: 'prompt обязателен' });
|
|
105
270
|
|
|
271
|
+
const s = getSettings();
|
|
272
|
+
|
|
273
|
+
// Chatium-путь для image и video.
|
|
274
|
+
if ((kind === 'video' || kind === 'image') && s.useChatium && s.chatium?.token && s.chatium?.base) {
|
|
275
|
+
// Отдельный mapping для Chatium — у него другой набор моделей.
|
|
276
|
+
const map = kind === 'video' ? CHATIUM_VIDEO_MODELS : CHATIUM_IMAGE_MODELS;
|
|
277
|
+
let fullModel;
|
|
278
|
+
if (modelKey) {
|
|
279
|
+
// Принимаем как короткий ключ (nano-banana-2), так и полный slug
|
|
280
|
+
// (bytedance/seedream-5-lite) — клиент может прислать любой формат.
|
|
281
|
+
fullModel = map[modelKey] || (Object.values(map).includes(modelKey) ? modelKey : null);
|
|
282
|
+
if (!fullModel) {
|
|
283
|
+
const known = Object.keys(map).join(', ');
|
|
284
|
+
return send(res, 400, {
|
|
285
|
+
error: `Модель "${modelKey}" не поддерживается Chatium для ${kind}. Доступны: ${known}. Чтобы использовать KIE-модели — отключите Chatium в настройках.`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const chatiumKind = kind === 'video' ? 'video' : 'image';
|
|
291
|
+
let chatiumBody;
|
|
292
|
+
if (kind === 'video') {
|
|
293
|
+
chatiumBody = { prompt, model: fullModel, imageInputs, videoInputs, aspectRatio, resolution, duration };
|
|
294
|
+
} else {
|
|
295
|
+
// Per-model особенности (см. spaces/api/execImageRunners.ts):
|
|
296
|
+
// - seedream-5-lite: только text-to-image (без imageInputs),
|
|
297
|
+
// output_format только 'jpeg' (не 'jpg').
|
|
298
|
+
// - nano-banana-2: jpg/png; image_input ОК.
|
|
299
|
+
// - grok-imagine: image_input игнорится для text-to-image.
|
|
300
|
+
const isSeedream = fullModel?.includes('seedream');
|
|
301
|
+
chatiumBody = {
|
|
302
|
+
prompt, model: fullModel,
|
|
303
|
+
aspectRatio, resolution,
|
|
304
|
+
outputFormat: isSeedream ? 'jpeg' : 'jpg',
|
|
305
|
+
};
|
|
306
|
+
if (!isSeedream && Array.isArray(imageInputs) && imageInputs.length) {
|
|
307
|
+
chatiumBody.imageInputs = imageInputs;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const taskId = await chatiumStart(s, chatiumKind, chatiumBody);
|
|
311
|
+
return send(res, 200, { taskId: 'chatium:' + taskId }, { 'X-Provider': 'chatium' });
|
|
312
|
+
} catch (e) {
|
|
313
|
+
logProviderCall('ERR ', 'Chatium', `/api/${kind}`, e.message);
|
|
314
|
+
return send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Прямой KIE (image и video).
|
|
319
|
+
if (!s.useKie) {
|
|
320
|
+
return send(res, 503, { error: kind === 'video'
|
|
321
|
+
? 'Видео-коннектор отключён. Включите Chatium или KIE.'
|
|
322
|
+
: 'Картиночный коннектор отключён. Включите Chatium или KIE.' });
|
|
323
|
+
}
|
|
324
|
+
|
|
106
325
|
const input = { prompt };
|
|
107
326
|
let model;
|
|
108
327
|
|
|
@@ -138,53 +357,149 @@ async function handleGenerate(req, res) {
|
|
|
138
357
|
if (videoInputs?.length) input.reference_video_urls = videoInputs;
|
|
139
358
|
if (aspectRatio) input.aspect_ratio = aspectRatio;
|
|
140
359
|
if (resolution) input.resolution = resolution;
|
|
360
|
+
if (duration) input.duration = +duration;
|
|
141
361
|
} else {
|
|
142
362
|
return send(res, 400, { error: `unknown kind: ${kind}` });
|
|
143
363
|
}
|
|
144
364
|
|
|
365
|
+
logProviderCall('POST', 'KIE', `${KIE_BASE}/api/v1/jobs/createTask`, `model=${model} kind=${kind}`);
|
|
145
366
|
const data = await kieFetch('/api/v1/jobs/createTask', {
|
|
146
367
|
method: 'POST',
|
|
147
368
|
body: JSON.stringify({ model, input }),
|
|
148
369
|
});
|
|
149
|
-
send(res, 200, { taskId: data.data?.taskId });
|
|
370
|
+
send(res, 200, { taskId: data.data?.taskId }, { 'X-Provider': 'kie' });
|
|
150
371
|
}
|
|
151
372
|
|
|
152
373
|
// ---------- /api/poll ----------
|
|
153
374
|
async function handlePoll(res, url) {
|
|
154
375
|
const taskId = url.searchParams.get('taskId');
|
|
155
376
|
if (!taskId) return send(res, 400, { error: 'нужен taskId' });
|
|
377
|
+
|
|
378
|
+
// Chatium-задача — определяем по префиксу «chatium:» который мы выставили
|
|
379
|
+
// в handleGenerate. Делегируем на chatiumPoll, мапим формат под клиент
|
|
380
|
+
// (он ждёт {status: 'done'|'error'|'pending', url, error, state}).
|
|
381
|
+
if (taskId.startsWith('chatium:')) {
|
|
382
|
+
const realId = taskId.slice('chatium:'.length);
|
|
383
|
+
const s = getSettings();
|
|
384
|
+
if (!s.chatium?.token || !s.chatium?.base) {
|
|
385
|
+
return send(res, 401, { status: 'error', error: 'Нет Chatium-сессии' }, { 'X-Provider': 'chatium' });
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
let d = await chatiumPoll(s, realId);
|
|
389
|
+
const provider = { 'X-Provider': 'chatium' };
|
|
390
|
+
if (d.status === 'completed') {
|
|
391
|
+
const url2 = d.result?.urls?.[0];
|
|
392
|
+
if (!url2) return send(res, 200, { status: 'error', error: 'Chatium вернул без URL' }, provider);
|
|
393
|
+
// Билинг приходит ОТДЕЛЬНЫМ callback'ом. Если creditsCharged ещё
|
|
394
|
+
// null, ждём до 5 сек — обычно billed подоспеет за секунду-две.
|
|
395
|
+
if (typeof d.creditsCharged !== 'number') {
|
|
396
|
+
const graceUntil = Date.now() + 5000;
|
|
397
|
+
while (Date.now() < graceUntil) {
|
|
398
|
+
await new Promise(r => setTimeout(r, 800));
|
|
399
|
+
const d2 = await chatiumPoll(s, realId);
|
|
400
|
+
if (typeof d2.creditsCharged === 'number') { d = d2; break; }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return send(res, 200, {
|
|
404
|
+
status: 'done',
|
|
405
|
+
url: url2,
|
|
406
|
+
cost: typeof d.creditsCharged === 'number' ? d.creditsCharged : null,
|
|
407
|
+
}, provider);
|
|
408
|
+
}
|
|
409
|
+
if (d.status === 'failed') {
|
|
410
|
+
return send(res, 200, { status: 'error', error: d.error || 'failed' }, provider);
|
|
411
|
+
}
|
|
412
|
+
return send(res, 200, { status: 'pending', state: d.status }, provider);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return send(res, 502, { status: 'error', error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// KIE-задача (старый путь).
|
|
419
|
+
logProviderCall('GET ', 'KIE', `${KIE_BASE}/api/v1/jobs/recordInfo`, `taskId=${taskId}`);
|
|
156
420
|
const data = await kieFetch(`/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`);
|
|
157
421
|
const d = data.data || {};
|
|
158
422
|
const st = d.state;
|
|
159
423
|
|
|
424
|
+
const provider = { 'X-Provider': 'kie' };
|
|
160
425
|
if (st === 'success') {
|
|
161
426
|
let urls = [];
|
|
162
427
|
try {
|
|
163
428
|
const parsed = JSON.parse(d.resultJson || '{}');
|
|
164
429
|
urls = parsed.resultUrls || [];
|
|
165
430
|
} catch {}
|
|
166
|
-
if (!urls.length) return send(res, 200, { status: 'error', error: 'KIE вернул успех, но без URL' });
|
|
167
|
-
send(res, 200, { status: 'done', url: urls[0] });
|
|
431
|
+
if (!urls.length) return send(res, 200, { status: 'error', error: 'KIE вернул успех, но без URL' }, provider);
|
|
432
|
+
send(res, 200, { status: 'done', url: urls[0] }, provider);
|
|
168
433
|
} else if (st === 'fail') {
|
|
169
|
-
send(res, 200, { status: 'error', error: d.failMsg || d.failCode || 'generation failed' });
|
|
434
|
+
send(res, 200, { status: 'error', error: d.failMsg || d.failCode || 'generation failed' }, provider);
|
|
170
435
|
} else {
|
|
171
|
-
send(res, 200, { status: 'pending', state: st });
|
|
436
|
+
send(res, 200, { status: 'pending', state: st }, provider);
|
|
172
437
|
}
|
|
173
438
|
}
|
|
174
439
|
|
|
175
|
-
// ---------- /api/upload (
|
|
440
|
+
// ---------- /api/upload (Chatium-storage ИЛИ KIE) ----------
|
|
176
441
|
async function handleUpload(req, res) {
|
|
177
442
|
const filename = decodeURIComponent(req.headers['x-file-name'] || 'upload.bin');
|
|
178
443
|
const mimeType = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
|
179
444
|
const buf = await readBody(req);
|
|
180
445
|
if (!buf.length) return send(res, 400, { error: 'пустой файл' });
|
|
181
|
-
|
|
446
|
+
if (buf.length > 50 * 1024 * 1024) {
|
|
447
|
+
return send(res, 413, { error: `файл слишком большой (${(buf.length/1024/1024).toFixed(1)} MB), лимит 50MB` });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const s = getSettings();
|
|
451
|
+
|
|
452
|
+
// Chatium-путь: получаем upload-URL → POST multipart Filedata → hash → URL.
|
|
453
|
+
if (s.useChatium && s.chatium?.token && s.chatium?.base) {
|
|
454
|
+
try {
|
|
455
|
+
// 1) Берём свежий upload-URL (он одноразовый, см. obtainStorageFilePutUrl).
|
|
456
|
+
const urlRequest = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.uploadUrl;
|
|
457
|
+
const ru = await fetch(urlRequest, {
|
|
458
|
+
headers: { 'Authorization': `Bearer ${s.chatium.token}` },
|
|
459
|
+
});
|
|
460
|
+
if (!ru.ok) {
|
|
461
|
+
const t = await ru.text();
|
|
462
|
+
return send(res, ru.status, { error: 'Chatium upload-url: ' + t.slice(0, 200) }, { 'X-Provider': 'chatium' });
|
|
463
|
+
}
|
|
464
|
+
const { uploadUrl } = await ru.json();
|
|
465
|
+
if (!uploadUrl) {
|
|
466
|
+
return send(res, 502, { error: 'Chatium не вернул uploadUrl' }, { 'X-Provider': 'chatium' });
|
|
467
|
+
}
|
|
468
|
+
// 2) Multipart-POST с полем "Filedata" — формат Chatium-fileservice.
|
|
469
|
+
logProviderCall('POST', 'Chatium', uploadUrl, `file=${filename} size=${buf.length}`);
|
|
470
|
+
const fd = new FormData();
|
|
471
|
+
fd.append('Filedata', new Blob([buf], { type: mimeType }), filename);
|
|
472
|
+
const ru2 = await fetch(uploadUrl, { method: 'POST', body: fd });
|
|
473
|
+
const hashOrErr = (await ru2.text()).trim();
|
|
474
|
+
if (!ru2.ok || !hashOrErr) {
|
|
475
|
+
return send(res, ru2.status || 502, {
|
|
476
|
+
error: `Chatium upload HTTP ${ru2.status}: ${hashOrErr.slice(0, 200)}`,
|
|
477
|
+
}, { 'X-Provider': 'chatium' });
|
|
478
|
+
}
|
|
479
|
+
// Response = plain text hash.
|
|
480
|
+
return send(res, 200, {
|
|
481
|
+
url: `${CHATIUM_CDN}/${hashOrErr}`,
|
|
482
|
+
fileName: filename,
|
|
483
|
+
size: buf.length,
|
|
484
|
+
hash: hashOrErr,
|
|
485
|
+
}, { 'X-Provider': 'chatium' });
|
|
486
|
+
} catch (e) {
|
|
487
|
+
logProviderCall('ERR ', 'Chatium', '/api/upload', e.message);
|
|
488
|
+
return send(res, 502, { error: 'Chatium upload: ' + e.message }, { 'X-Provider': 'chatium' });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Прямой KIE (fallback).
|
|
493
|
+
if (!s.useKie) {
|
|
494
|
+
return send(res, 503, { error: 'Upload-коннектор отключён. Включите Chatium или KIE.' });
|
|
495
|
+
}
|
|
182
496
|
if (buf.length > 10 * 1024 * 1024) {
|
|
183
|
-
return send(res, 413, { error: `файл слишком большой (${(buf.length/1024/1024).toFixed(1)} MB), лимит 10MB` });
|
|
497
|
+
return send(res, 413, { error: `файл слишком большой для KIE (${(buf.length/1024/1024).toFixed(1)} MB), лимит 10MB` });
|
|
184
498
|
}
|
|
185
499
|
const dataUrl = `data:${mimeType};base64,${buf.toString('base64')}`;
|
|
186
500
|
const key = process.env.KIE_API_KEY;
|
|
187
501
|
if (!key) return send(res, 500, { error: 'KIE_API_KEY не задан' });
|
|
502
|
+
logProviderCall('POST', 'KIE', 'https://kieai.redpandaai.co/api/file-base64-upload', `file=${filename} size=${buf.length}`);
|
|
188
503
|
const r = await fetch('https://kieai.redpandaai.co/api/file-base64-upload', {
|
|
189
504
|
method: 'POST',
|
|
190
505
|
headers: {
|
|
@@ -202,7 +517,7 @@ async function handleUpload(req, res) {
|
|
|
202
517
|
if (!r.ok || !data.success) {
|
|
203
518
|
return send(res, r.status || 500, { error: data.msg || `upload failed: ${text.slice(0, 200)}` });
|
|
204
519
|
}
|
|
205
|
-
send(res, 200, { url: data.data.downloadUrl, fileName: data.data.fileName, size: data.data.fileSize });
|
|
520
|
+
send(res, 200, { url: data.data.downloadUrl, fileName: data.data.fileName, size: data.data.fileSize }, { 'X-Provider': 'kie' });
|
|
206
521
|
}
|
|
207
522
|
|
|
208
523
|
// ---------- /api/proxy?url=... (стрим бинарных данных через бэк, минуя CORS) ----------
|
|
@@ -232,6 +547,7 @@ async function handleProxy(res, url) {
|
|
|
232
547
|
async function handleVoices(req, res) {
|
|
233
548
|
const key = process.env.ELEVENLABS_API_KEY;
|
|
234
549
|
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
550
|
+
logProviderCall('GET ', 'ElevenLabs', `${ELEVEN_BASE}/v1/voices`);
|
|
235
551
|
const r = await fetch(`${ELEVEN_BASE}/v1/voices`, { headers: { 'xi-api-key': key } });
|
|
236
552
|
const text = await r.text();
|
|
237
553
|
let data; try { data = JSON.parse(text); } catch { data = {}; }
|
|
@@ -243,18 +559,49 @@ async function handleVoices(req, res) {
|
|
|
243
559
|
labels: v.labels || {},
|
|
244
560
|
preview: v.preview_url,
|
|
245
561
|
}));
|
|
246
|
-
send(res, 200, { voices });
|
|
562
|
+
send(res, 200, { voices }, { 'X-Provider': 'elevenlabs' });
|
|
247
563
|
}
|
|
248
564
|
|
|
249
|
-
// ---------- /api/text (
|
|
565
|
+
// ---------- /api/text (Chatium proxy ИЛИ OpenRouter) ----------
|
|
250
566
|
// Тело: {prompt, model?, system?, images?: [{url}]}
|
|
251
|
-
// images.url — data:image/...;base64,... либо http(s) URL. Если массив не
|
|
252
|
-
// пустой, отправляем content[] с image_url-блоками (vision).
|
|
253
567
|
async function handleText(req, res) {
|
|
568
|
+
const body = await readJson(req);
|
|
569
|
+
const { prompt, model = 'anthropic/claude-sonnet-4', system, images } = body;
|
|
570
|
+
if (!prompt) return send(res, 400, { error: 'нужен prompt' });
|
|
571
|
+
|
|
572
|
+
const s = getSettings();
|
|
573
|
+
|
|
574
|
+
// Chatium-путь: useChatium=true И есть валидный токен.
|
|
575
|
+
if (s.useChatium && s.chatium?.token && s.chatium?.base) {
|
|
576
|
+
try {
|
|
577
|
+
// Chatium ждёт messages в @start/sdk-формате (image как { type: 'image',
|
|
578
|
+
// source: { type: 'url', url } }). Если у клиента images — конвертируем.
|
|
579
|
+
const chatiumBody = {
|
|
580
|
+
prompt,
|
|
581
|
+
model,
|
|
582
|
+
system,
|
|
583
|
+
images: Array.isArray(images) ? images.filter(i => i?.url) : undefined,
|
|
584
|
+
};
|
|
585
|
+
const taskId = await chatiumStart(s, 'text', chatiumBody);
|
|
586
|
+
const result = await chatiumWait(s, taskId, 120000);
|
|
587
|
+
return send(res, 200, {
|
|
588
|
+
text: result.result?.text || '',
|
|
589
|
+
model,
|
|
590
|
+
cost: typeof result.creditsCharged === 'number' ? result.creditsCharged : null,
|
|
591
|
+
}, { 'X-Provider': 'chatium' });
|
|
592
|
+
} catch (e) {
|
|
593
|
+
logProviderCall('ERR ', 'Chatium', '/api/text', e.message);
|
|
594
|
+
return send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Прямой OpenRouter (если useOpenrouter включён).
|
|
599
|
+
if (!s.useOpenrouter) {
|
|
600
|
+
return send(res, 503, { error: 'Текстовый коннектор отключён в настройках. Включите Chatium или OpenRouter.' });
|
|
601
|
+
}
|
|
254
602
|
const key = process.env.OPENROUTER_API_KEY;
|
|
255
603
|
if (!key) return send(res, 500, { error: 'OPENROUTER_API_KEY не задан' });
|
|
256
|
-
|
|
257
|
-
if (!prompt) return send(res, 400, { error: 'нужен prompt' });
|
|
604
|
+
|
|
258
605
|
const messages = [];
|
|
259
606
|
if (system) messages.push({ role: 'system', content: system });
|
|
260
607
|
if (Array.isArray(images) && images.length) {
|
|
@@ -267,6 +614,7 @@ async function handleText(req, res) {
|
|
|
267
614
|
} else {
|
|
268
615
|
messages.push({ role: 'user', content: prompt });
|
|
269
616
|
}
|
|
617
|
+
logProviderCall('POST', 'OpenRouter', 'https://openrouter.ai/api/v1/chat/completions', `model=${model} prompt=${prompt.length}ch images=${images?.length || 0}`);
|
|
270
618
|
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
271
619
|
method: 'POST',
|
|
272
620
|
headers: {
|
|
@@ -283,17 +631,74 @@ async function handleText(req, res) {
|
|
|
283
631
|
return send(res, r.status, { error: data?.error?.message || data?.raw || `HTTP ${r.status}` });
|
|
284
632
|
}
|
|
285
633
|
const content = data?.choices?.[0]?.message?.content || '';
|
|
286
|
-
send(res, 200, { text: content, model: data?.model || model });
|
|
634
|
+
send(res, 200, { text: content, model: data?.model || model }, { 'X-Provider': 'openrouter' });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------- /api/transactions (только Chatium) — история списаний ----------
|
|
638
|
+
async function handleTransactions(req, res) {
|
|
639
|
+
const s = getSettings();
|
|
640
|
+
if (!s.useChatium || !s.chatium?.token || !s.chatium?.base) {
|
|
641
|
+
return send(res, 503, { error: 'Chatium не подключён' });
|
|
642
|
+
}
|
|
643
|
+
const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.transactions;
|
|
644
|
+
logProviderCall('GET ', 'Chatium', url);
|
|
645
|
+
try {
|
|
646
|
+
const r = await fetch(url, { headers: { 'Authorization': `Bearer ${s.chatium.token}` } });
|
|
647
|
+
const text = await r.text();
|
|
648
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
649
|
+
if (!r.ok) {
|
|
650
|
+
return send(res, r.status, {
|
|
651
|
+
error: data?.reason || data?.error || `HTTP ${r.status}`,
|
|
652
|
+
}, { 'X-Provider': 'chatium' });
|
|
653
|
+
}
|
|
654
|
+
send(res, 200, data, { 'X-Provider': 'chatium' });
|
|
655
|
+
} catch (e) {
|
|
656
|
+
send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
|
|
657
|
+
}
|
|
287
658
|
}
|
|
288
659
|
|
|
289
|
-
// ---------- /api/
|
|
660
|
+
// ---------- /api/balance (только Chatium) ----------
|
|
661
|
+
async function handleBalance(req, res) {
|
|
662
|
+
const s = getSettings();
|
|
663
|
+
if (!s.useChatium || !s.chatium?.token || !s.chatium?.base) {
|
|
664
|
+
return send(res, 503, { error: 'Chatium не подключён' });
|
|
665
|
+
}
|
|
666
|
+
const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.balance;
|
|
667
|
+
logProviderCall('GET ', 'Chatium', url);
|
|
668
|
+
try {
|
|
669
|
+
const r = await fetch(url, {
|
|
670
|
+
headers: { 'Authorization': `Bearer ${s.chatium.token}` },
|
|
671
|
+
});
|
|
672
|
+
const text = await r.text();
|
|
673
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
674
|
+
if (!r.ok) {
|
|
675
|
+
return send(res, r.status, {
|
|
676
|
+
error: data?.reason || data?.error || `HTTP ${r.status}`,
|
|
677
|
+
}, { 'X-Provider': 'chatium' });
|
|
678
|
+
}
|
|
679
|
+
send(res, 200, data, { 'X-Provider': 'chatium' });
|
|
680
|
+
} catch (e) {
|
|
681
|
+
send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------- /api/sfx (Chatium ИЛИ ElevenLabs Sound Effects) ----------
|
|
290
686
|
async function handleSfx(req, res) {
|
|
291
|
-
const key = process.env.ELEVENLABS_API_KEY;
|
|
292
|
-
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
293
687
|
const { text, durationSeconds, promptInfluence = 0.3 } = await readJson(req);
|
|
294
688
|
if (!text) return send(res, 400, { error: 'нужен text' });
|
|
689
|
+
const s = getSettings();
|
|
690
|
+
|
|
691
|
+
if (s.useChatium && s.chatium?.token && s.chatium?.base) {
|
|
692
|
+
return handleAudioViaChatium(res, s, { kind: 'sfx', text, durationSeconds });
|
|
693
|
+
}
|
|
694
|
+
if (!s.useElevenlabs) {
|
|
695
|
+
return send(res, 503, { error: 'Аудио-коннектор отключён. Включите Chatium или ElevenLabs.' });
|
|
696
|
+
}
|
|
697
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
698
|
+
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
295
699
|
const body = { text, prompt_influence: promptInfluence };
|
|
296
700
|
if (durationSeconds) body.duration_seconds = +durationSeconds;
|
|
701
|
+
logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/sound-generation`, `text=${text.length}ch dur=${durationSeconds || '-'}`);
|
|
297
702
|
const r = await fetch(`${ELEVEN_BASE}/v1/sound-generation`, {
|
|
298
703
|
method: 'POST',
|
|
299
704
|
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
|
|
@@ -303,24 +708,27 @@ async function handleSfx(req, res) {
|
|
|
303
708
|
const t = await r.text();
|
|
304
709
|
return send(res, r.status, { error: t.slice(0, 400) });
|
|
305
710
|
}
|
|
306
|
-
res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
|
|
307
|
-
|
|
308
|
-
while (true) {
|
|
309
|
-
const { done, value } = await reader.read();
|
|
310
|
-
if (done) break;
|
|
311
|
-
res.write(Buffer.from(value));
|
|
312
|
-
}
|
|
313
|
-
res.end();
|
|
711
|
+
res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
|
|
712
|
+
await streamReaderToResponse(r.body.getReader(), res);
|
|
314
713
|
}
|
|
315
714
|
|
|
316
|
-
// ---------- /api/music (ElevenLabs Music) ----------
|
|
715
|
+
// ---------- /api/music (Chatium ИЛИ ElevenLabs Music) ----------
|
|
317
716
|
async function handleMusic(req, res) {
|
|
318
|
-
const key = process.env.ELEVENLABS_API_KEY;
|
|
319
|
-
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
320
717
|
const { prompt, durationMs } = await readJson(req);
|
|
321
718
|
if (!prompt) return send(res, 400, { error: 'нужен prompt' });
|
|
719
|
+
const s = getSettings();
|
|
720
|
+
|
|
721
|
+
if (s.useChatium && s.chatium?.token && s.chatium?.base) {
|
|
722
|
+
return handleAudioViaChatium(res, s, { kind: 'music', prompt, durationMs });
|
|
723
|
+
}
|
|
724
|
+
if (!s.useElevenlabs) {
|
|
725
|
+
return send(res, 503, { error: 'Аудио-коннектор отключён. Включите Chatium или ElevenLabs.' });
|
|
726
|
+
}
|
|
727
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
728
|
+
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
322
729
|
const body = { prompt };
|
|
323
730
|
if (durationMs) body.music_length_ms = +durationMs;
|
|
731
|
+
logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/music`, `prompt=${prompt.length}ch dur_ms=${durationMs || '-'}`);
|
|
324
732
|
const r = await fetch(`${ELEVEN_BASE}/v1/music`, {
|
|
325
733
|
method: 'POST',
|
|
326
734
|
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
|
|
@@ -330,22 +738,25 @@ async function handleMusic(req, res) {
|
|
|
330
738
|
const t = await r.text();
|
|
331
739
|
return send(res, r.status, { error: t.slice(0, 400) });
|
|
332
740
|
}
|
|
333
|
-
res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
|
|
334
|
-
|
|
335
|
-
while (true) {
|
|
336
|
-
const { done, value } = await reader.read();
|
|
337
|
-
if (done) break;
|
|
338
|
-
res.write(Buffer.from(value));
|
|
339
|
-
}
|
|
340
|
-
res.end();
|
|
741
|
+
res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
|
|
742
|
+
await streamReaderToResponse(r.body.getReader(), res);
|
|
341
743
|
}
|
|
342
744
|
|
|
343
|
-
// ---------- /api/tts (ElevenLabs v3) ----------
|
|
745
|
+
// ---------- /api/tts (Chatium ИЛИ ElevenLabs v3) ----------
|
|
344
746
|
async function handleTts(req, res) {
|
|
345
|
-
const key = process.env.ELEVENLABS_API_KEY;
|
|
346
|
-
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
347
747
|
const { text, voiceId = 'JBFqnCBsd6RMkjVDRZzb', modelId = 'eleven_v3' } = await readJson(req);
|
|
348
748
|
if (!text) return send(res, 400, { error: 'нужен text' });
|
|
749
|
+
const s = getSettings();
|
|
750
|
+
|
|
751
|
+
if (s.useChatium && s.chatium?.token && s.chatium?.base) {
|
|
752
|
+
return handleAudioViaChatium(res, s, { kind: 'tts', text, voice: voiceId, model: modelId });
|
|
753
|
+
}
|
|
754
|
+
if (!s.useElevenlabs) {
|
|
755
|
+
return send(res, 503, { error: 'Аудио-коннектор отключён. Включите Chatium или ElevenLabs.' });
|
|
756
|
+
}
|
|
757
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
758
|
+
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
759
|
+
logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, `model=${modelId} text=${text.length}ch`);
|
|
349
760
|
const r = await fetch(`${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, {
|
|
350
761
|
method: 'POST',
|
|
351
762
|
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
|
|
@@ -355,8 +766,37 @@ async function handleTts(req, res) {
|
|
|
355
766
|
const t = await r.text();
|
|
356
767
|
return send(res, r.status, { error: t.slice(0, 300) });
|
|
357
768
|
}
|
|
358
|
-
res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
|
|
359
|
-
|
|
769
|
+
res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
|
|
770
|
+
await streamReaderToResponse(r.body.getReader(), res);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Шлём audio-задачу в Chatium /api/audio, ждём готовности, скачиваем URL и
|
|
774
|
+
// стримим клиенту как audio/mpeg (клиент ожидает blob, не URL).
|
|
775
|
+
async function handleAudioViaChatium(res, s, body) {
|
|
776
|
+
try {
|
|
777
|
+
const taskId = await chatiumStart(s, 'audio', body);
|
|
778
|
+
const result = await chatiumWait(s, taskId, 180000);
|
|
779
|
+
const url = result.result?.urls?.[0];
|
|
780
|
+
if (!url) {
|
|
781
|
+
return send(res, 502, { error: 'Chatium завершил аудио-задачу без URL результата' }, { 'X-Provider': 'chatium' });
|
|
782
|
+
}
|
|
783
|
+
logProviderCall('GET ', 'Chatium', url, '(downloading audio)');
|
|
784
|
+
const headers = { 'X-Provider': 'chatium' };
|
|
785
|
+
if (typeof result.creditsCharged === 'number') {
|
|
786
|
+
headers['X-Cost-Credits'] = String(result.creditsCharged);
|
|
787
|
+
}
|
|
788
|
+
await streamUrlToResponse(url, res, 'audio/mpeg', headers);
|
|
789
|
+
} catch (e) {
|
|
790
|
+
logProviderCall('ERR ', 'Chatium', '/api/audio', e.message);
|
|
791
|
+
if (!res.headersSent) {
|
|
792
|
+
send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
|
|
793
|
+
} else {
|
|
794
|
+
res.end();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function streamReaderToResponse(reader, res) {
|
|
360
800
|
while (true) {
|
|
361
801
|
const { done, value } = await reader.read();
|
|
362
802
|
if (done) break;
|
|
@@ -395,6 +835,8 @@ const server = createServer(async (req, res) => {
|
|
|
395
835
|
if (req.method === 'POST' && url.pathname === '/api/sfx') return handleSfx(req, res);
|
|
396
836
|
if (req.method === 'POST' && url.pathname === '/api/music') return handleMusic(req, res);
|
|
397
837
|
if (req.method === 'POST' && url.pathname === '/api/text') return handleText(req, res);
|
|
838
|
+
if (req.method === 'GET' && url.pathname === '/api/balance') return handleBalance(req, res);
|
|
839
|
+
if (req.method === 'GET' && url.pathname === '/api/transactions') return handleTransactions(req, res);
|
|
398
840
|
if (req.method === 'GET') return serveStatic(res, url);
|
|
399
841
|
send(res, 404, 'not found');
|
|
400
842
|
} catch (e) {
|
|
@@ -407,7 +849,10 @@ const server = createServer(async (req, res) => {
|
|
|
407
849
|
process.on('unhandledRejection', (err) => console.error('[unhandledRejection]', err));
|
|
408
850
|
process.on('uncaughtException', (err) => console.error('[uncaughtException]', err));
|
|
409
851
|
|
|
410
|
-
function start(port = PORT) {
|
|
852
|
+
function start(port = PORT, opts = {}) {
|
|
853
|
+
if (typeof opts.getSettings === 'function') {
|
|
854
|
+
getSettings = opts.getSettings;
|
|
855
|
+
}
|
|
411
856
|
return new Promise((resolveOk, reject) => {
|
|
412
857
|
server.once('error', reject);
|
|
413
858
|
server.listen(port, () => {
|
|
@@ -415,6 +860,8 @@ function start(port = PORT) {
|
|
|
415
860
|
console.log(`▶ http://localhost:${addr.port}`);
|
|
416
861
|
console.log(` KIE_API_KEY: ${process.env.KIE_API_KEY ? '✓' : '✗ missing'}`);
|
|
417
862
|
console.log(` ELEVENLABS_API_KEY: ${process.env.ELEVENLABS_API_KEY ? '✓' : '✗ missing'}`);
|
|
863
|
+
const s = getSettings();
|
|
864
|
+
console.log(` providers: chatium=${s.useChatium !== false ? '✓' : '✗'} openrouter=${s.useOpenrouter !== false ? '✓' : '✗'} elevenlabs=${s.useElevenlabs !== false ? '✓' : '✗'} kie=${s.useKie !== false ? '✓' : '✗'}`);
|
|
418
865
|
resolveOk(addr.port);
|
|
419
866
|
});
|
|
420
867
|
});
|