kingkont 0.7.39 → 0.7.41

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/server.js CHANGED
@@ -1,10 +1,19 @@
1
- // Локальный Node.js сервер: статика + прокси к KIE / ElevenLabs.
2
- // Запуск: node server.js (требуется Node 18+)
1
+ // Локальный HTTP-сервер. Тонкая обёртка над lib/providers здесь только
2
+ // роутинг, парсинг тел запросов и сериализация ответов. Вся логика общения с
3
+ // провайдерами (Chatium, KIE, ElevenLabs, OpenRouter) — в lib/providers.js,
4
+ // чтобы CLI (bin/kingkont.js) мог использовать тот же код.
5
+ //
6
+ // Запуск: node server.js (требуется Node 18+).
7
+
8
+ 'use strict';
9
+
3
10
  const { createServer } = require('node:http');
4
11
  const { readFile, stat } = require('node:fs/promises');
5
12
  const { readFileSync, existsSync } = require('node:fs');
6
13
  const { extname, join, normalize, resolve } = require('node:path');
7
14
 
15
+ const providers = require('./lib/providers');
16
+
8
17
  // ---------- .env loader (без зависимостей) ----------
9
18
  function loadEnv() {
10
19
  const path = resolve(__dirname, '.env');
@@ -24,13 +33,10 @@ function loadEnv() {
24
33
  loadEnv();
25
34
 
26
35
  const PORT = parseInt(process.env.PORT || '8000', 10);
27
- const KIE_BASE = 'https://api.kie.ai';
28
- const ELEVEN_BASE = 'https://api.elevenlabs.io';
29
36
  const ROOT = __dirname;
30
37
 
31
38
  // Источник live-настроек (use*-флаги, chatium token). Подставляется через
32
- // start({ getSettings }) из main.js. Если не передан — fallback на пустые
33
- // настройки, и server.js работает как раньше через прямые провайдеры.
39
+ // start({ getSettings }) из main.js. Если не передан — fallback на пустые.
34
40
  let getSettings = () => ({});
35
41
 
36
42
  const MIME = {
@@ -49,142 +55,9 @@ const MIME = {
49
55
  '.wasm': 'application/wasm',
50
56
  };
51
57
 
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, 800)}`);
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
- }
58
+ // =============================================================================
59
+ // HTTP helpers
60
+ // =============================================================================
188
61
 
189
62
  function send(res, status, body, headers = {}) {
190
63
  const isStr = typeof body === 'string';
@@ -206,321 +79,51 @@ async function readJson(req) {
206
79
  return buf.length ? JSON.parse(buf.toString('utf-8')) : {};
207
80
  }
208
81
 
209
- async function kieFetch(path, options = {}) {
210
- const key = process.env.KIE_API_KEY;
211
- if (!key) throw new Error('KIE_API_KEY не задан в .env');
212
- const r = await fetch(KIE_BASE + path, {
213
- ...options,
214
- headers: {
215
- 'Authorization': `Bearer ${key}`,
216
- 'Content-Type': 'application/json',
217
- ...(options.headers || {}),
218
- },
219
- });
220
- const text = await r.text();
221
- let data;
222
- try { data = JSON.parse(text); } catch { data = { raw: text }; }
223
- if (!r.ok) throw new Error(`KIE ${r.status}: ${(data.msg || text).slice(0, 300)}`);
224
- if (data.code && data.code !== 200) throw new Error(`KIE: ${data.msg || 'unknown error'}`);
225
- return data;
82
+ function sendError(res, e, defaultStatus = 500, providerHeader = null) {
83
+ const status = e.statusCode || defaultStatus;
84
+ const headers = providerHeader ? { 'X-Provider': providerHeader } : {};
85
+ send(res, status, { error: e.message || 'server error' }, headers);
226
86
  }
227
87
 
228
- // Модели KIE: ключ → реальный model id
229
- const IMAGE_MODELS = {
230
- 'nano-banana-2': 'nano-banana-2',
231
- 'grok': 'grok-imagine/text-to-image',
232
- 'seedream': 'seedream/4.5-text-to-image',
233
- 'seedream-5-lite': 'seedream/5-lite-text-to-image',
234
- // Быстрые модели — для черновых правок и быстрых превью.
235
- 'flux-schnell': 'flux/schnell',
236
- 'sdxl-lightning': 'sdxl/lightning',
237
- };
238
- const VIDEO_MODELS = {
239
- 'seedance-2': 'bytedance/seedance-2',
240
- };
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
- };
88
+ // =============================================================================
89
+ // Route handlers — каждый делегирует в lib/providers.
90
+ // =============================================================================
264
91
 
265
- // ---------- /api/generate ----------
266
92
  async function handleGenerate(req, res) {
267
93
  const body = await readJson(req);
268
94
  const { kind, prompt, imageInputs, videoInputs, aspectRatio, resolution, duration, model: modelKey } = body;
269
95
  if (!prompt || !prompt.trim()) return send(res, 400, { error: 'prompt обязателен' });
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': 'kingkont' });
312
- } catch (e) {
313
- logProviderCall('ERR ', 'Chatium', `/api/${kind}`, e.message);
314
- return send(res, 502, { error: 'KingKont: ' + e.message }, { 'X-Provider': 'kingkont' });
315
- }
316
- }
317
-
318
- // Прямой KIE (image и video).
319
- if (!s.useKie) {
320
- return send(res, 503, { error: kind === 'video'
321
- ? 'Войдите в KingKont или KIE для видео.'
322
- : 'Войдите в KingKont или KIE для картинок.' });
323
- }
324
-
325
- const input = { prompt };
326
- let model;
327
-
328
- if (kind === 'image') {
329
- const key = modelKey || 'nano-banana-2';
330
- model = IMAGE_MODELS[key];
331
- if (!model) return send(res, 400, { error: `unknown image model: ${key}` });
332
- if (key === 'nano-banana-2') {
333
- if (imageInputs?.length) input.image_input = imageInputs;
334
- if (aspectRatio) input.aspect_ratio = aspectRatio;
335
- input.resolution = resolution || '1K';
336
- input.output_format = 'jpg';
337
- } else if (key === 'grok') {
338
- // Grok-Imagine не принимает image_input — игнорируем референсы
339
- if (aspectRatio) input.aspect_ratio = aspectRatio;
340
- input.nsfw_checker = false;
341
- input.enable_pro = false;
342
- } else if (key === 'seedream' || key === 'seedream-5-lite') {
343
- // Seedream 4.5 / 5 Lite: только text-to-image, image_input не поддерживается
344
- input.aspect_ratio = aspectRatio || '16:9';
345
- input.quality = 'high';
346
- input.nsfw_checker = false;
347
- } else if (key === 'flux-schnell' || key === 'sdxl-lightning') {
348
- // Быстрые text-to-image: минимум параметров, низкие шаги, рендер ~3-5с.
349
- if (aspectRatio) input.aspect_ratio = aspectRatio;
350
- input.output_format = 'jpg';
351
- }
352
- } else if (kind === 'video') {
353
- const key = modelKey || 'seedance-2';
354
- model = VIDEO_MODELS[key];
355
- if (!model) return send(res, 400, { error: `unknown video model: ${key}` });
356
- if (imageInputs?.length) input.reference_image_urls = imageInputs;
357
- if (videoInputs?.length) input.reference_video_urls = videoInputs;
358
- if (aspectRatio) input.aspect_ratio = aspectRatio;
359
- if (resolution) input.resolution = resolution;
360
- if (duration) input.duration = +duration;
361
- } else {
362
- return send(res, 400, { error: `unknown kind: ${kind}` });
363
- }
364
-
365
- logProviderCall('POST', 'KIE', `${KIE_BASE}/api/v1/jobs/createTask`, `model=${model} kind=${kind}`);
366
- const data = await kieFetch('/api/v1/jobs/createTask', {
367
- method: 'POST',
368
- body: JSON.stringify({ model, input }),
369
- });
370
- send(res, 200, { taskId: data.data?.taskId }, { 'X-Provider': 'kie' });
96
+ try {
97
+ const r = await providers.startGeneration({
98
+ kind, prompt, modelKey, imageInputs, videoInputs, aspectRatio, resolution, duration,
99
+ settings: getSettings(),
100
+ });
101
+ send(res, 200, { taskId: r.taskId }, { 'X-Provider': r.provider });
102
+ } catch (e) { sendError(res, e, 502, e.message?.includes('KIE') ? 'kie' : 'kingkont'); }
371
103
  }
372
104
 
373
- // ---------- /api/poll ----------
374
105
  async function handlePoll(res, url) {
375
106
  const taskId = url.searchParams.get('taskId');
376
107
  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': 'kingkont' });
386
- }
387
- try {
388
- let d = await chatiumPoll(s, realId);
389
- const provider = { 'X-Provider': 'kingkont' };
390
- if (d.status === 'completed') {
391
- const url2 = d.result?.urls?.[0];
392
- if (!url2) return send(res, 200, { status: 'error', error: 'KingKont вернул без 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: 'KingKont: ' + e.message }, { 'X-Provider': 'kingkont' });
415
- }
416
- }
417
-
418
- // KIE-задача (старый путь).
419
- logProviderCall('GET ', 'KIE', `${KIE_BASE}/api/v1/jobs/recordInfo`, `taskId=${taskId}`);
420
- const data = await kieFetch(`/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`);
421
- const d = data.data || {};
422
- const st = d.state;
423
-
424
- const provider = { 'X-Provider': 'kie' };
425
- if (st === 'success') {
426
- let urls = [];
427
- try {
428
- const parsed = JSON.parse(d.resultJson || '{}');
429
- urls = parsed.resultUrls || [];
430
- } catch {}
431
- if (!urls.length) return send(res, 200, { status: 'error', error: 'KIE вернул успех, но без URL' }, provider);
432
- send(res, 200, { status: 'done', url: urls[0] }, provider);
433
- } else if (st === 'fail') {
434
- send(res, 200, { status: 'error', error: d.failMsg || d.failCode || 'generation failed' }, provider);
435
- } else {
436
- send(res, 200, { status: 'pending', state: st }, provider);
437
- }
108
+ try {
109
+ const r = await providers.pollGeneration(taskId, getSettings());
110
+ const headers = { 'X-Provider': r.provider };
111
+ if (r.status === 'done') return send(res, 200, { status: 'done', url: r.url, cost: r.cost ?? null }, headers);
112
+ if (r.status === 'error') return send(res, 200, { status: 'error', error: r.error }, headers);
113
+ return send(res, 200, { status: 'pending', state: r.state }, headers);
114
+ } catch (e) { sendError(res, e, 502); }
438
115
  }
439
116
 
440
- // ---------- /api/upload (Chatium-storage ИЛИ KIE) ----------
441
117
  async function handleUpload(req, res) {
442
118
  const filename = decodeURIComponent(req.headers['x-file-name'] || 'upload.bin');
443
- const mimeType = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
444
- const buf = await readBody(req);
445
- if (!buf.length) return send(res, 400, { error: 'пустой файл' });
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: 'KingKont upload-url: ' + t.slice(0, 200) }, { 'X-Provider': 'kingkont' });
463
- }
464
- const { uploadUrl } = await ru.json();
465
- if (!uploadUrl) {
466
- return send(res, 502, { error: 'KingKont не вернул uploadUrl' }, { 'X-Provider': 'kingkont' });
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': 'kingkont' });
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': 'kingkont' });
486
- } catch (e) {
487
- logProviderCall('ERR ', 'Chatium', '/api/upload', e.message);
488
- return send(res, 502, { error: 'KingKont upload: ' + e.message }, { 'X-Provider': 'kingkont' });
489
- }
490
- }
491
-
492
- // Прямой KIE (fallback).
493
- if (!s.useKie) {
494
- return send(res, 503, { error: 'Войдите в KingKont или KIE для загрузки файлов.' });
495
- }
496
- if (buf.length > 10 * 1024 * 1024) {
497
- return send(res, 413, { error: `файл слишком большой для KIE (${(buf.length/1024/1024).toFixed(1)} MB), лимит 10MB` });
498
- }
499
- const dataUrl = `data:${mimeType};base64,${buf.toString('base64')}`;
500
- const key = process.env.KIE_API_KEY;
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}`);
503
- const r = await fetch('https://kieai.redpandaai.co/api/file-base64-upload', {
504
- method: 'POST',
505
- headers: {
506
- 'Authorization': `Bearer ${key}`,
507
- 'Content-Type': 'application/json',
508
- },
509
- body: JSON.stringify({
510
- base64Data: dataUrl,
511
- uploadPath: 'images',
512
- fileName: filename,
513
- }),
514
- });
515
- const text = await r.text();
516
- let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
517
- if (!r.ok || !data.success) {
518
- return send(res, r.status || 500, { error: data.msg || `upload failed: ${text.slice(0, 200)}` });
519
- }
520
- send(res, 200, { url: data.data.downloadUrl, fileName: data.data.fileName, size: data.data.fileSize }, { 'X-Provider': 'kie' });
119
+ const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
120
+ const buffer = await readBody(req);
121
+ try {
122
+ const r = await providers.uploadFile({ buffer, filename, mime, settings: getSettings() });
123
+ send(res, 200, { url: r.url, fileName: r.fileName, size: r.size, hash: r.hash }, { 'X-Provider': r.provider });
124
+ } catch (e) { sendError(res, e, 502); }
521
125
  }
522
126
 
523
- // ---------- /api/proxy?url=... (стрим бинарных данных через бэк, минуя CORS) ----------
524
127
  async function handleProxy(res, url) {
525
128
  const target = url.searchParams.get('url');
526
129
  if (!target) return send(res, 400, { error: 'нужен url' });
@@ -543,366 +146,81 @@ async function handleProxy(res, url) {
543
146
  res.end();
544
147
  }
545
148
 
546
- // ---------- /api/voices (список голосов ElevenLabs) ----------
547
149
  async function handleVoices(req, res) {
548
- const key = process.env.ELEVENLABS_API_KEY;
549
- if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
550
- logProviderCall('GET ', 'ElevenLabs', `${ELEVEN_BASE}/v1/voices`);
551
- const r = await fetch(`${ELEVEN_BASE}/v1/voices`, { headers: { 'xi-api-key': key } });
552
- const text = await r.text();
553
- let data; try { data = JSON.parse(text); } catch { data = {}; }
554
- if (!r.ok) return send(res, r.status, { error: data?.detail?.message || `HTTP ${r.status}` });
555
- const voices = (data.voices || []).map(v => ({
556
- id: v.voice_id,
557
- name: v.name,
558
- category: v.category,
559
- labels: v.labels || {},
560
- preview: v.preview_url,
561
- }));
562
- send(res, 200, { voices }, { 'X-Provider': 'elevenlabs' });
150
+ try {
151
+ const voices = await providers.listElevenVoices();
152
+ send(res, 200, { voices }, { 'X-Provider': 'elevenlabs' });
153
+ } catch (e) { sendError(res, e); }
563
154
  }
564
155
 
565
- // ---------- /api/text (Chatium proxy ИЛИ OpenRouter) ----------
566
- // Тело: {prompt, model?, system?, images?: [{url}]}
567
156
  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': 'kingkont' });
592
- } catch (e) {
593
- logProviderCall('ERR ', 'Chatium', '/api/text', e.message);
594
- return send(res, 502, { error: 'KingKont: ' + e.message }, { 'X-Provider': 'kingkont' });
595
- }
596
- }
597
-
598
- // Прямой OpenRouter (если useOpenrouter включён).
599
- if (!s.useOpenrouter) {
600
- return send(res, 503, { error: 'Войдите в KingKont или OpenRouter для генерации текста.' });
601
- }
602
- const key = process.env.OPENROUTER_API_KEY;
603
- if (!key) return send(res, 500, { error: 'OPENROUTER_API_KEY не задан' });
604
-
605
- const messages = [];
606
- if (system) messages.push({ role: 'system', content: system });
607
- if (Array.isArray(images) && images.length) {
608
- const content = [{ type: 'text', text: prompt }];
609
- for (const img of images) {
610
- if (!img?.url) continue;
611
- content.push({ type: 'image_url', image_url: { url: img.url } });
612
- }
613
- messages.push({ role: 'user', content });
614
- } else {
615
- messages.push({ role: 'user', content: prompt });
616
- }
617
- logProviderCall('POST', 'OpenRouter', 'https://openrouter.ai/api/v1/chat/completions', `model=${model} prompt=${prompt.length}ch images=${images?.length || 0}`);
618
- const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
619
- method: 'POST',
620
- headers: {
621
- 'Authorization': `Bearer ${key}`,
622
- 'Content-Type': 'application/json',
623
- 'HTTP-Referer': 'http://localhost',
624
- 'X-Title': 'KingKont',
625
- },
626
- body: JSON.stringify({ model, messages }),
627
- });
628
- const text = await r.text();
629
- let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
630
- if (!r.ok) {
631
- return send(res, r.status, { error: data?.error?.message || data?.raw || `HTTP ${r.status}` });
632
- }
633
- const content = data?.choices?.[0]?.message?.content || '';
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: 'KingKont не подключён' });
642
- }
643
- const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.transactions;
644
- logProviderCall('GET ', 'Chatium', url);
645
157
  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': 'kingkont' });
653
- }
654
- send(res, 200, data, { 'X-Provider': 'kingkont' });
655
- } catch (e) {
656
- send(res, 502, { error: 'KingKont: ' + e.message }, { 'X-Provider': 'kingkont' });
657
- }
158
+ const body = await readJson(req);
159
+ const r = await providers.generateText({ ...body, settings: getSettings() });
160
+ send(res, 200, { text: r.text, model: r.model, cost: r.cost }, { 'X-Provider': r.provider });
161
+ } catch (e) { sendError(res, e, 502); }
658
162
  }
659
163
 
660
- // ---------- /api/balance (KingKont — legacy single-balance endpoint) ----------
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: 'KingKont не подключён' });
665
- }
666
- const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.balance;
667
- logProviderCall('GET ', 'Chatium', url);
164
+ async function handleTts(req, res) {
668
165
  try {
669
- const r = await fetch(url, {
670
- headers: { 'Authorization': `Bearer ${s.chatium.token}` },
166
+ const body = await readJson(req);
167
+ const r = await providers.generateTts({ ...body, settings: getSettings() });
168
+ res.writeHead(200, {
169
+ 'Content-Type': r.mime,
170
+ 'X-Provider': r.provider,
171
+ ...(r.cost != null ? { 'X-Cost-Credits': String(r.cost) } : {}),
671
172
  });
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': 'kingkont' });
678
- }
679
- send(res, 200, data, { 'X-Provider': 'kingkont' });
680
- } catch (e) {
681
- send(res, 502, { error: 'KingKont: ' + e.message }, { 'X-Provider': 'kingkont' });
682
- }
173
+ res.end(r.buffer);
174
+ } catch (e) { sendError(res, e, 502); }
683
175
  }
684
176
 
685
- // ---------- /api/balance/all (агрегированные балансы всех включённых провайдеров) ----------
686
- async function handleBalanceAll(req, res) {
687
- const s = getSettings();
688
- const out = {};
689
- // KingKont — credits.
690
- if (s.useChatium && s.chatium?.token && s.chatium?.base) {
691
- try {
692
- const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.balance;
693
- const r = await fetch(url, { headers: { 'Authorization': `Bearer ${s.chatium.token}` } });
694
- const d = await r.json().catch(() => ({}));
695
- if (r.ok && typeof d.balance === 'number') out.kingkont = { unit: 'credits', amount: d.balance };
696
- } catch {}
697
- }
698
- // ElevenLabs — characters_left = limit - used.
699
- if (s.useElevenlabs && process.env.ELEVENLABS_API_KEY) {
700
- try {
701
- const r = await fetch(`${ELEVEN_BASE}/v1/user/subscription`, {
702
- headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY },
703
- });
704
- const d = await r.json().catch(() => ({}));
705
- if (r.ok && typeof d.character_count === 'number' && typeof d.character_limit === 'number') {
706
- out.elevenlabs = {
707
- unit: 'chars',
708
- amount: Math.max(0, d.character_limit - d.character_count),
709
- limit: d.character_limit,
710
- };
711
- }
712
- } catch {}
713
- }
714
- // OpenRouter — credits в USD.
715
- if (s.useOpenrouter && process.env.OPENROUTER_API_KEY) {
716
- try {
717
- const r = await fetch('https://openrouter.ai/api/v1/credits', {
718
- headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}` },
719
- });
720
- const d = await r.json().catch(() => ({}));
721
- if (r.ok && d.data && typeof d.data.total_credits === 'number') {
722
- out.openrouter = {
723
- unit: 'usd',
724
- amount: Math.max(0, (d.data.total_credits || 0) - (d.data.total_usage || 0)),
725
- };
726
- }
727
- } catch {}
728
- }
729
- // KIE — нет публичного balance-endpoint в их API; пропускаем.
730
- send(res, 200, out);
731
- }
732
-
733
- // ---------- /api/sfx (Chatium ИЛИ ElevenLabs Sound Effects) ----------
734
177
  async function handleSfx(req, res) {
735
- const { text, durationSeconds, promptInfluence = 0.3 } = await readJson(req);
736
- if (!text) return send(res, 400, { error: 'нужен text' });
737
- const s = getSettings();
738
-
739
- if (s.useChatium && s.chatium?.token && s.chatium?.base) {
740
- return handleAudioViaChatium(res, s, { kind: 'sfx', text, durationSeconds });
741
- }
742
- if (!s.useElevenlabs) {
743
- return send(res, 503, { error: 'Войдите в KingKont или ElevenLabs для аудио.' });
744
- }
745
- const key = process.env.ELEVENLABS_API_KEY;
746
- if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
747
- const body = { text, prompt_influence: promptInfluence };
748
- if (durationSeconds) body.duration_seconds = +durationSeconds;
749
- logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/sound-generation`, `text=${text.length}ch dur=${durationSeconds || '-'}`);
750
- const r = await fetch(`${ELEVEN_BASE}/v1/sound-generation`, {
751
- method: 'POST',
752
- headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
753
- body: JSON.stringify(body),
754
- });
755
- if (!r.ok) {
756
- const t = await r.text();
757
- return send(res, r.status, { error: t.slice(0, 400) });
758
- }
759
- res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
760
- await streamReaderToResponse(r.body.getReader(), res);
178
+ try {
179
+ const body = await readJson(req);
180
+ const r = await providers.generateSfx({ ...body, settings: getSettings() });
181
+ res.writeHead(200, {
182
+ 'Content-Type': r.mime,
183
+ 'X-Provider': r.provider,
184
+ ...(r.cost != null ? { 'X-Cost-Credits': String(r.cost) } : {}),
185
+ });
186
+ res.end(r.buffer);
187
+ } catch (e) { sendError(res, e, 502); }
761
188
  }
762
189
 
763
- // ---------- /api/music (Chatium ИЛИ ElevenLabs Music) ----------
764
190
  async function handleMusic(req, res) {
765
- const { prompt, durationMs } = await readJson(req);
766
- if (!prompt) return send(res, 400, { error: 'нужен prompt' });
767
- const s = getSettings();
768
-
769
- if (s.useChatium && s.chatium?.token && s.chatium?.base) {
770
- return handleAudioViaChatium(res, s, { kind: 'music', prompt, durationMs });
771
- }
772
- if (!s.useElevenlabs) {
773
- return send(res, 503, { error: 'Войдите в KingKont или ElevenLabs для аудио.' });
774
- }
775
- const key = process.env.ELEVENLABS_API_KEY;
776
- if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
777
- const body = { prompt };
778
- if (durationMs) body.music_length_ms = +durationMs;
779
- logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/music`, `prompt=${prompt.length}ch dur_ms=${durationMs || '-'}`);
780
- const r = await fetch(`${ELEVEN_BASE}/v1/music`, {
781
- method: 'POST',
782
- headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
783
- body: JSON.stringify(body),
784
- });
785
- if (!r.ok) {
786
- const t = await r.text();
787
- return send(res, r.status, { error: t.slice(0, 400) });
788
- }
789
- res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
790
- await streamReaderToResponse(r.body.getReader(), res);
191
+ try {
192
+ const body = await readJson(req);
193
+ const r = await providers.generateMusic({ ...body, settings: getSettings() });
194
+ res.writeHead(200, {
195
+ 'Content-Type': r.mime,
196
+ 'X-Provider': r.provider,
197
+ ...(r.cost != null ? { 'X-Cost-Credits': String(r.cost) } : {}),
198
+ });
199
+ res.end(r.buffer);
200
+ } catch (e) { sendError(res, e, 502); }
791
201
  }
792
202
 
793
- // ---------- /api/tts (Chatium ИЛИ ElevenLabs v3) ----------
794
- // Body клиента (всё опц. кроме text):
795
- // {
796
- // text,
797
- // ttsModel?: 'qwen/qwen3-tts' | 'elevenlabs/v3' | 'minimax/speech-02-hd'
798
- // | 'google/gemini-3.1-flash-tts-preview',
799
- // voice?: string, // voiceId/speaker зависит от модели
800
- // voiceId?: string, // legacy alias для voice (ElevenLabs)
801
- // modelId?: string, // legacy: 'eleven_v3' и т.д.
802
- // // Любые per-provider параметры пробрасываются в Chatium как есть:
803
- // stability, similarity_boost, style, speed, language_code,
804
- // pitch, volume, emotion, sample_rate, audio_format, language_boost,
805
- // mode, speaker, voice_description, style_instruction, ...
806
- // }
807
- const TTS_PASSTHROUGH = new Set([
808
- 'voice', 'voiceId', 'voice_id', 'speaker',
809
- 'language', 'language_code', 'language_boost',
810
- 'speed', 'pitch', 'volume',
811
- 'stability', 'similarity_boost', 'style', 'style_instruction',
812
- 'audio_format', 'sample_rate', 'bitrate', 'channel', 'emotion',
813
- 'subtitle_enable', 'english_normalization',
814
- 'voice_description', 'reference_audio', 'reference_text', 'mode',
815
- 'previous_text', 'next_text',
816
- ]);
817
- async function handleTts(req, res) {
818
- const body = await readJson(req);
819
- const text = body.text;
820
- if (!text) return send(res, 400, { error: 'нужен text' });
821
- const s = getSettings();
822
-
823
- // Приоритет: если ElevenLabs включён С ключом — идём прямо туда (юзер
824
- // ожидает свои кастомные/cloned голоса из ElevenLabs API).
825
- // Иначе — через KingKont (там 26-голосный enum eleven_v3 + qwen/minimax/gemini).
826
- const directElevenAvailable = s.useElevenlabs && process.env.ELEVENLABS_API_KEY;
827
- if (s.useChatium && s.chatium?.token && s.chatium?.base && !directElevenAvailable) {
828
- const ttsBody = { kind: 'tts', text };
829
- // Маппинг легаси `modelId` → `model` (старый клиент шлёт modelId='eleven_v3').
830
- if (body.ttsModel) ttsBody.model = body.ttsModel;
831
- else if (body.modelId === 'eleven_v3') ttsBody.model = 'elevenlabs/v3';
832
- else if (body.modelId) ttsBody.model = body.modelId;
833
- // voice: поддерживаем оба имени (voice или voiceId).
834
- if (body.voice) ttsBody.voice = body.voice;
835
- else if (body.voiceId) ttsBody.voice = body.voiceId;
836
- // Per-model passthrough.
837
- for (const k of Object.keys(body)) {
838
- if (k === 'text' || k === 'voice' || k === 'voiceId' || k === 'ttsModel' || k === 'modelId') continue;
839
- if (TTS_PASSTHROUGH.has(k)) ttsBody[k] = body[k];
840
- }
841
- return handleAudioViaChatium(res, s, ttsBody);
842
- }
843
-
844
- // Прямой ElevenLabs (только eleven_v3, остальные модели только через Chatium).
845
- if (!s.useElevenlabs) {
846
- return send(res, 503, { error: 'Войдите в KingKont или ElevenLabs для аудио.' });
847
- }
848
- const key = process.env.ELEVENLABS_API_KEY;
849
- if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
850
- const voiceId = body.voiceId || body.voice || 'JBFqnCBsd6RMkjVDRZzb';
851
- // Принудительно eleven_v3 для прямого подключения — это та модель которую
852
- // мы поддерживали с самого начала (тоны, кастомные voices). Игнорируем
853
- // body.modelId / body.ttsModel: они могут содержать KingKont-slug
854
- // ('elevenlabs/v3'), не валидный для самого ElevenLabs API.
855
- const modelId = 'eleven_v3';
856
- logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, `model=${modelId} text=${text.length}ch`);
857
- const r = await fetch(`${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, {
858
- method: 'POST',
859
- headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
860
- body: JSON.stringify({ text, model_id: modelId }),
861
- });
862
- if (!r.ok) {
863
- const t = await r.text();
864
- return send(res, r.status, { error: t.slice(0, 300) });
865
- }
866
- res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
867
- await streamReaderToResponse(r.body.getReader(), res);
203
+ async function handleBalance(res) {
204
+ try {
205
+ const all = await providers.fetchBalances(getSettings());
206
+ if (all.kingkont) return send(res, 200, { balance: all.kingkont.amount }, { 'X-Provider': 'kingkont' });
207
+ return send(res, 503, { error: 'KingKont не подключён' });
208
+ } catch (e) { sendError(res, e, 502); }
868
209
  }
869
210
 
870
- // Шлём audio-задачу в Chatium /api/audio, ждём готовности, скачиваем URL и
871
- // стримим клиенту как audio/mpeg (клиент ожидает blob, не URL).
872
- async function handleAudioViaChatium(res, s, body) {
873
- try {
874
- const taskId = await chatiumStart(s, 'audio', body);
875
- const result = await chatiumWait(s, taskId, 180000);
876
- const url = result.result?.urls?.[0];
877
- if (!url) {
878
- return send(res, 502, { error: 'Chatium завершил аудио-задачу без URL результата' }, { 'X-Provider': 'kingkont' });
879
- }
880
- logProviderCall('GET ', 'Chatium', url, '(downloading audio)');
881
- const headers = { 'X-Provider': 'kingkont' };
882
- if (typeof result.creditsCharged === 'number') {
883
- headers['X-Cost-Credits'] = String(result.creditsCharged);
884
- }
885
- await streamUrlToResponse(url, res, 'audio/mpeg', headers);
886
- } catch (e) {
887
- logProviderCall('ERR ', 'Chatium', '/api/audio', e.message);
888
- if (!res.headersSent) {
889
- send(res, 502, { error: 'KingKont: ' + e.message }, { 'X-Provider': 'kingkont' });
890
- } else {
891
- res.end();
892
- }
893
- }
211
+ async function handleBalanceAll(res) {
212
+ try { send(res, 200, await providers.fetchBalances(getSettings())); }
213
+ catch (e) { sendError(res, e, 502); }
894
214
  }
895
215
 
896
- async function streamReaderToResponse(reader, res) {
897
- while (true) {
898
- const { done, value } = await reader.read();
899
- if (done) break;
900
- res.write(Buffer.from(value));
901
- }
902
- res.end();
216
+ async function handleTransactions(res) {
217
+ try { send(res, 200, await providers.fetchTransactions(getSettings()), { 'X-Provider': 'kingkont' }); }
218
+ catch (e) { sendError(res, e, 502); }
903
219
  }
904
220
 
905
- // ---------- статика ----------
221
+ // =============================================================================
222
+ // Static files (renderer assets).
223
+ // =============================================================================
906
224
  async function serveStatic(res, url) {
907
225
  let pathname = url.pathname === '/' ? '/index.html' : decodeURIComponent(url.pathname);
908
226
  const filePath = normalize(join(ROOT, pathname));
@@ -919,7 +237,10 @@ async function serveStatic(res, url) {
919
237
  }
920
238
  }
921
239
 
922
- // ---------- роутер ----------
240
+ // =============================================================================
241
+ // Router
242
+ // =============================================================================
243
+
923
244
  const server = createServer(async (req, res) => {
924
245
  const url = new URL(req.url, `http://${req.headers.host}`);
925
246
  try {
@@ -932,9 +253,9 @@ const server = createServer(async (req, res) => {
932
253
  if (req.method === 'POST' && url.pathname === '/api/sfx') return handleSfx(req, res);
933
254
  if (req.method === 'POST' && url.pathname === '/api/music') return handleMusic(req, res);
934
255
  if (req.method === 'POST' && url.pathname === '/api/text') return handleText(req, res);
935
- if (req.method === 'GET' && url.pathname === '/api/balance') return handleBalance(req, res);
936
- if (req.method === 'GET' && url.pathname === '/api/balance/all') return handleBalanceAll(req, res);
937
- if (req.method === 'GET' && url.pathname === '/api/transactions') return handleTransactions(req, res);
256
+ if (req.method === 'GET' && url.pathname === '/api/balance') return handleBalance(res);
257
+ if (req.method === 'GET' && url.pathname === '/api/balance/all') return handleBalanceAll(res);
258
+ if (req.method === 'GET' && url.pathname === '/api/transactions') return handleTransactions(res);
938
259
  if (req.method === 'GET') return serveStatic(res, url);
939
260
  send(res, 404, 'not found');
940
261
  } catch (e) {
@@ -943,14 +264,11 @@ const server = createServer(async (req, res) => {
943
264
  }
944
265
  });
945
266
 
946
- // Не падать на необработанных ошибках — логируем и живём дальше
947
267
  process.on('unhandledRejection', (err) => console.error('[unhandledRejection]', err));
948
268
  process.on('uncaughtException', (err) => console.error('[uncaughtException]', err));
949
269
 
950
270
  function start(port = PORT, opts = {}) {
951
- if (typeof opts.getSettings === 'function') {
952
- getSettings = opts.getSettings;
953
- }
271
+ if (typeof opts.getSettings === 'function') getSettings = opts.getSettings;
954
272
  return new Promise((resolveOk, reject) => {
955
273
  server.once('error', reject);
956
274
  server.listen(port, () => {
@@ -965,8 +283,6 @@ function start(port = PORT, opts = {}) {
965
283
  });
966
284
  }
967
285
 
968
- if (require.main === module) {
969
- start();
970
- }
286
+ if (require.main === module) start();
971
287
 
972
288
  module.exports = { start };