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/assets/PROJECT_CLAUDE.md +31 -132
- package/bin/kingkont.js +59 -49
- package/index.html +9 -10137
- package/lib/cli.js +504 -0
- package/lib/projectFs.js +391 -0
- package/lib/providers.js +689 -0
- package/lib/settings.js +70 -0
- package/main.js +28 -0
- package/package.json +6 -1
- package/preload.js +7 -0
- package/renderer/board.js +1522 -0
- package/renderer/generate.js +1727 -0
- package/renderer/media.js +1413 -0
- package/renderer/settings.js +1128 -0
- package/renderer/state.js +566 -0
- package/renderer/styles.css +1018 -0
- package/renderer/timeline.js +2836 -0
- package/server.js +103 -787
- package/settings.html +56 -0
- package/skill/SKILL.md +160 -78
package/server.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
// Локальный
|
|
2
|
-
//
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
|
670
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
897
|
-
|
|
898
|
-
|
|
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(
|
|
936
|
-
if (req.method === 'GET' && url.pathname === '/api/balance/all') return handleBalanceAll(
|
|
937
|
-
if (req.method === 'GET' && url.pathname === '/api/transactions') return handleTransactions(
|
|
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 };
|