kingkont 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/assets/PROJECT_CLAUDE.md +149 -0
- package/assets/logo-1024.png +0 -0
- package/assets/logo-256.png +0 -0
- package/assets/logo-512.png +0 -0
- package/assets/logo-square.svg +53 -0
- package/assets/logo.icns +0 -0
- package/assets/logo.iconset/icon_1024x1024.png +0 -0
- package/assets/logo.iconset/icon_128x128.png +0 -0
- package/assets/logo.iconset/icon_128x128@2x.png +0 -0
- package/assets/logo.iconset/icon_16x16.png +0 -0
- package/assets/logo.iconset/icon_16x16@2x.png +0 -0
- package/assets/logo.iconset/icon_256x256.png +0 -0
- package/assets/logo.iconset/icon_256x256@2x.png +0 -0
- package/assets/logo.iconset/icon_32x32.png +0 -0
- package/assets/logo.iconset/icon_32x32@2x.png +0 -0
- package/assets/logo.iconset/icon_512x512.png +0 -0
- package/assets/logo.iconset/icon_512x512@2x.png +0 -0
- package/assets/logo.iconset/icon_64x64.png +0 -0
- package/assets/logo.svg +53 -0
- package/bin/kingkont.js +88 -0
- package/index.html +9465 -0
- package/main.js +356 -0
- package/package.json +60 -0
- package/preload.js +51 -0
- package/scripts/patch-electron-name.sh +70 -0
- package/server.js +427 -0
package/server.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// Локальный Node.js сервер: статика + прокси к KIE / ElevenLabs.
|
|
2
|
+
// Запуск: node server.js (требуется Node 18+)
|
|
3
|
+
const { createServer } = require('node:http');
|
|
4
|
+
const { readFile, stat } = require('node:fs/promises');
|
|
5
|
+
const { readFileSync, existsSync } = require('node:fs');
|
|
6
|
+
const { extname, join, normalize, resolve } = require('node:path');
|
|
7
|
+
|
|
8
|
+
// ---------- .env loader (без зависимостей) ----------
|
|
9
|
+
function loadEnv() {
|
|
10
|
+
const path = resolve(__dirname, '.env');
|
|
11
|
+
if (!existsSync(path)) return;
|
|
12
|
+
for (const raw of readFileSync(path, 'utf-8').split('\n')) {
|
|
13
|
+
const line = raw.trim();
|
|
14
|
+
if (!line || line.startsWith('#')) continue;
|
|
15
|
+
const eq = line.indexOf('=');
|
|
16
|
+
if (eq < 0) continue;
|
|
17
|
+
const key = line.slice(0, eq).trim();
|
|
18
|
+
let val = line.slice(eq + 1).trim();
|
|
19
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
20
|
+
(val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
|
|
21
|
+
if (!(key in process.env)) process.env[key] = val;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
loadEnv();
|
|
25
|
+
|
|
26
|
+
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
|
+
const ROOT = __dirname;
|
|
30
|
+
|
|
31
|
+
const MIME = {
|
|
32
|
+
'.html': 'text/html; charset=utf-8',
|
|
33
|
+
'.css': 'text/css; charset=utf-8',
|
|
34
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
35
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
36
|
+
'.json': 'application/json; charset=utf-8',
|
|
37
|
+
'.svg': 'image/svg+xml',
|
|
38
|
+
'.png': 'image/png',
|
|
39
|
+
'.jpg': 'image/jpeg',
|
|
40
|
+
'.jpeg': 'image/jpeg',
|
|
41
|
+
'.gif': 'image/gif',
|
|
42
|
+
'.webp': 'image/webp',
|
|
43
|
+
'.ico': 'image/x-icon',
|
|
44
|
+
'.wasm': 'application/wasm',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------- helpers ----------
|
|
48
|
+
function send(res, status, body, headers = {}) {
|
|
49
|
+
const isStr = typeof body === 'string';
|
|
50
|
+
res.writeHead(status, {
|
|
51
|
+
'Content-Type': isStr ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8',
|
|
52
|
+
'Cache-Control': 'no-store',
|
|
53
|
+
...headers,
|
|
54
|
+
});
|
|
55
|
+
res.end(isStr ? body : JSON.stringify(body));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readBody(req) {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
for await (const c of req) chunks.push(c);
|
|
61
|
+
return Buffer.concat(chunks);
|
|
62
|
+
}
|
|
63
|
+
async function readJson(req) {
|
|
64
|
+
const buf = await readBody(req);
|
|
65
|
+
return buf.length ? JSON.parse(buf.toString('utf-8')) : {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function kieFetch(path, options = {}) {
|
|
69
|
+
const key = process.env.KIE_API_KEY;
|
|
70
|
+
if (!key) throw new Error('KIE_API_KEY не задан в .env');
|
|
71
|
+
const r = await fetch(KIE_BASE + path, {
|
|
72
|
+
...options,
|
|
73
|
+
headers: {
|
|
74
|
+
'Authorization': `Bearer ${key}`,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
...(options.headers || {}),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const text = await r.text();
|
|
80
|
+
let data;
|
|
81
|
+
try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
82
|
+
if (!r.ok) throw new Error(`KIE ${r.status}: ${(data.msg || text).slice(0, 300)}`);
|
|
83
|
+
if (data.code && data.code !== 200) throw new Error(`KIE: ${data.msg || 'unknown error'}`);
|
|
84
|
+
return data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Модели KIE: ключ → реальный model id
|
|
88
|
+
const IMAGE_MODELS = {
|
|
89
|
+
'nano-banana-2': 'nano-banana-2',
|
|
90
|
+
'grok': 'grok-imagine/text-to-image',
|
|
91
|
+
'seedream': 'seedream/4.5-text-to-image',
|
|
92
|
+
'seedream-5-lite': 'seedream/5-lite-text-to-image',
|
|
93
|
+
// Быстрые модели — для черновых правок и быстрых превью.
|
|
94
|
+
'flux-schnell': 'flux/schnell',
|
|
95
|
+
'sdxl-lightning': 'sdxl/lightning',
|
|
96
|
+
};
|
|
97
|
+
const VIDEO_MODELS = {
|
|
98
|
+
'seedance-2': 'bytedance/seedance-2',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------- /api/generate ----------
|
|
102
|
+
async function handleGenerate(req, res) {
|
|
103
|
+
const { kind, prompt, imageInputs, videoInputs, aspectRatio, resolution, model: modelKey } = await readJson(req);
|
|
104
|
+
if (!prompt || !prompt.trim()) return send(res, 400, { error: 'prompt обязателен' });
|
|
105
|
+
|
|
106
|
+
const input = { prompt };
|
|
107
|
+
let model;
|
|
108
|
+
|
|
109
|
+
if (kind === 'image') {
|
|
110
|
+
const key = modelKey || 'nano-banana-2';
|
|
111
|
+
model = IMAGE_MODELS[key];
|
|
112
|
+
if (!model) return send(res, 400, { error: `unknown image model: ${key}` });
|
|
113
|
+
if (key === 'nano-banana-2') {
|
|
114
|
+
if (imageInputs?.length) input.image_input = imageInputs;
|
|
115
|
+
if (aspectRatio) input.aspect_ratio = aspectRatio;
|
|
116
|
+
input.resolution = resolution || '1K';
|
|
117
|
+
input.output_format = 'jpg';
|
|
118
|
+
} else if (key === 'grok') {
|
|
119
|
+
// Grok-Imagine не принимает image_input — игнорируем референсы
|
|
120
|
+
if (aspectRatio) input.aspect_ratio = aspectRatio;
|
|
121
|
+
input.nsfw_checker = false;
|
|
122
|
+
input.enable_pro = false;
|
|
123
|
+
} else if (key === 'seedream' || key === 'seedream-5-lite') {
|
|
124
|
+
// Seedream 4.5 / 5 Lite: только text-to-image, image_input не поддерживается
|
|
125
|
+
input.aspect_ratio = aspectRatio || '16:9';
|
|
126
|
+
input.quality = 'high';
|
|
127
|
+
input.nsfw_checker = false;
|
|
128
|
+
} else if (key === 'flux-schnell' || key === 'sdxl-lightning') {
|
|
129
|
+
// Быстрые text-to-image: минимум параметров, низкие шаги, рендер ~3-5с.
|
|
130
|
+
if (aspectRatio) input.aspect_ratio = aspectRatio;
|
|
131
|
+
input.output_format = 'jpg';
|
|
132
|
+
}
|
|
133
|
+
} else if (kind === 'video') {
|
|
134
|
+
const key = modelKey || 'seedance-2';
|
|
135
|
+
model = VIDEO_MODELS[key];
|
|
136
|
+
if (!model) return send(res, 400, { error: `unknown video model: ${key}` });
|
|
137
|
+
if (imageInputs?.length) input.reference_image_urls = imageInputs;
|
|
138
|
+
if (videoInputs?.length) input.reference_video_urls = videoInputs;
|
|
139
|
+
if (aspectRatio) input.aspect_ratio = aspectRatio;
|
|
140
|
+
if (resolution) input.resolution = resolution;
|
|
141
|
+
} else {
|
|
142
|
+
return send(res, 400, { error: `unknown kind: ${kind}` });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const data = await kieFetch('/api/v1/jobs/createTask', {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
body: JSON.stringify({ model, input }),
|
|
148
|
+
});
|
|
149
|
+
send(res, 200, { taskId: data.data?.taskId });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- /api/poll ----------
|
|
153
|
+
async function handlePoll(res, url) {
|
|
154
|
+
const taskId = url.searchParams.get('taskId');
|
|
155
|
+
if (!taskId) return send(res, 400, { error: 'нужен taskId' });
|
|
156
|
+
const data = await kieFetch(`/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`);
|
|
157
|
+
const d = data.data || {};
|
|
158
|
+
const st = d.state;
|
|
159
|
+
|
|
160
|
+
if (st === 'success') {
|
|
161
|
+
let urls = [];
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(d.resultJson || '{}');
|
|
164
|
+
urls = parsed.resultUrls || [];
|
|
165
|
+
} catch {}
|
|
166
|
+
if (!urls.length) return send(res, 200, { status: 'error', error: 'KIE вернул успех, но без URL' });
|
|
167
|
+
send(res, 200, { status: 'done', url: urls[0] });
|
|
168
|
+
} else if (st === 'fail') {
|
|
169
|
+
send(res, 200, { status: 'error', error: d.failMsg || d.failCode || 'generation failed' });
|
|
170
|
+
} else {
|
|
171
|
+
send(res, 200, { status: 'pending', state: st });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- /api/upload (загружает файл в KIE, возвращает публичный URL) ----------
|
|
176
|
+
async function handleUpload(req, res) {
|
|
177
|
+
const filename = decodeURIComponent(req.headers['x-file-name'] || 'upload.bin');
|
|
178
|
+
const mimeType = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
|
179
|
+
const buf = await readBody(req);
|
|
180
|
+
if (!buf.length) return send(res, 400, { error: 'пустой файл' });
|
|
181
|
+
// KIE base64 endpoint рекомендован для файлов < 10MB
|
|
182
|
+
if (buf.length > 10 * 1024 * 1024) {
|
|
183
|
+
return send(res, 413, { error: `файл слишком большой (${(buf.length/1024/1024).toFixed(1)} MB), лимит 10MB` });
|
|
184
|
+
}
|
|
185
|
+
const dataUrl = `data:${mimeType};base64,${buf.toString('base64')}`;
|
|
186
|
+
const key = process.env.KIE_API_KEY;
|
|
187
|
+
if (!key) return send(res, 500, { error: 'KIE_API_KEY не задан' });
|
|
188
|
+
const r = await fetch('https://kieai.redpandaai.co/api/file-base64-upload', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: {
|
|
191
|
+
'Authorization': `Bearer ${key}`,
|
|
192
|
+
'Content-Type': 'application/json',
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
base64Data: dataUrl,
|
|
196
|
+
uploadPath: 'images',
|
|
197
|
+
fileName: filename,
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
const text = await r.text();
|
|
201
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
202
|
+
if (!r.ok || !data.success) {
|
|
203
|
+
return send(res, r.status || 500, { error: data.msg || `upload failed: ${text.slice(0, 200)}` });
|
|
204
|
+
}
|
|
205
|
+
send(res, 200, { url: data.data.downloadUrl, fileName: data.data.fileName, size: data.data.fileSize });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------- /api/proxy?url=... (стрим бинарных данных через бэк, минуя CORS) ----------
|
|
209
|
+
async function handleProxy(res, url) {
|
|
210
|
+
const target = url.searchParams.get('url');
|
|
211
|
+
if (!target) return send(res, 400, { error: 'нужен url' });
|
|
212
|
+
let u;
|
|
213
|
+
try { u = new URL(target); } catch { return send(res, 400, { error: 'битый url' }); }
|
|
214
|
+
if (!/^https?:$/.test(u.protocol)) return send(res, 400, { error: 'разрешены только http/https' });
|
|
215
|
+
|
|
216
|
+
const r = await fetch(target);
|
|
217
|
+
if (!r.ok) return send(res, r.status, { error: `upstream ${r.status}` });
|
|
218
|
+
const headers = { 'Content-Type': r.headers.get('content-type') || 'application/octet-stream' };
|
|
219
|
+
const len = r.headers.get('content-length');
|
|
220
|
+
if (len) headers['Content-Length'] = len;
|
|
221
|
+
res.writeHead(200, headers);
|
|
222
|
+
const reader = r.body.getReader();
|
|
223
|
+
while (true) {
|
|
224
|
+
const { done, value } = await reader.read();
|
|
225
|
+
if (done) break;
|
|
226
|
+
res.write(Buffer.from(value));
|
|
227
|
+
}
|
|
228
|
+
res.end();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------- /api/voices (список голосов ElevenLabs) ----------
|
|
232
|
+
async function handleVoices(req, res) {
|
|
233
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
234
|
+
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
235
|
+
const r = await fetch(`${ELEVEN_BASE}/v1/voices`, { headers: { 'xi-api-key': key } });
|
|
236
|
+
const text = await r.text();
|
|
237
|
+
let data; try { data = JSON.parse(text); } catch { data = {}; }
|
|
238
|
+
if (!r.ok) return send(res, r.status, { error: data?.detail?.message || `HTTP ${r.status}` });
|
|
239
|
+
const voices = (data.voices || []).map(v => ({
|
|
240
|
+
id: v.voice_id,
|
|
241
|
+
name: v.name,
|
|
242
|
+
category: v.category,
|
|
243
|
+
labels: v.labels || {},
|
|
244
|
+
preview: v.preview_url,
|
|
245
|
+
}));
|
|
246
|
+
send(res, 200, { voices });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------- /api/text (OpenRouter chat completion) ----------
|
|
250
|
+
// Тело: {prompt, model?, system?, images?: [{url}]}
|
|
251
|
+
// images.url — data:image/...;base64,... либо http(s) URL. Если массив не
|
|
252
|
+
// пустой, отправляем content[] с image_url-блоками (vision).
|
|
253
|
+
async function handleText(req, res) {
|
|
254
|
+
const key = process.env.OPENROUTER_API_KEY;
|
|
255
|
+
if (!key) return send(res, 500, { error: 'OPENROUTER_API_KEY не задан' });
|
|
256
|
+
const { prompt, model = 'anthropic/claude-sonnet-4', system, images } = await readJson(req);
|
|
257
|
+
if (!prompt) return send(res, 400, { error: 'нужен prompt' });
|
|
258
|
+
const messages = [];
|
|
259
|
+
if (system) messages.push({ role: 'system', content: system });
|
|
260
|
+
if (Array.isArray(images) && images.length) {
|
|
261
|
+
const content = [{ type: 'text', text: prompt }];
|
|
262
|
+
for (const img of images) {
|
|
263
|
+
if (!img?.url) continue;
|
|
264
|
+
content.push({ type: 'image_url', image_url: { url: img.url } });
|
|
265
|
+
}
|
|
266
|
+
messages.push({ role: 'user', content });
|
|
267
|
+
} else {
|
|
268
|
+
messages.push({ role: 'user', content: prompt });
|
|
269
|
+
}
|
|
270
|
+
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: {
|
|
273
|
+
'Authorization': `Bearer ${key}`,
|
|
274
|
+
'Content-Type': 'application/json',
|
|
275
|
+
'HTTP-Referer': 'http://localhost',
|
|
276
|
+
'X-Title': 'KingKont',
|
|
277
|
+
},
|
|
278
|
+
body: JSON.stringify({ model, messages }),
|
|
279
|
+
});
|
|
280
|
+
const text = await r.text();
|
|
281
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
282
|
+
if (!r.ok) {
|
|
283
|
+
return send(res, r.status, { error: data?.error?.message || data?.raw || `HTTP ${r.status}` });
|
|
284
|
+
}
|
|
285
|
+
const content = data?.choices?.[0]?.message?.content || '';
|
|
286
|
+
send(res, 200, { text: content, model: data?.model || model });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------- /api/sfx (ElevenLabs Sound Effects) ----------
|
|
290
|
+
async function handleSfx(req, res) {
|
|
291
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
292
|
+
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
293
|
+
const { text, durationSeconds, promptInfluence = 0.3 } = await readJson(req);
|
|
294
|
+
if (!text) return send(res, 400, { error: 'нужен text' });
|
|
295
|
+
const body = { text, prompt_influence: promptInfluence };
|
|
296
|
+
if (durationSeconds) body.duration_seconds = +durationSeconds;
|
|
297
|
+
const r = await fetch(`${ELEVEN_BASE}/v1/sound-generation`, {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
|
|
300
|
+
body: JSON.stringify(body),
|
|
301
|
+
});
|
|
302
|
+
if (!r.ok) {
|
|
303
|
+
const t = await r.text();
|
|
304
|
+
return send(res, r.status, { error: t.slice(0, 400) });
|
|
305
|
+
}
|
|
306
|
+
res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
|
|
307
|
+
const reader = r.body.getReader();
|
|
308
|
+
while (true) {
|
|
309
|
+
const { done, value } = await reader.read();
|
|
310
|
+
if (done) break;
|
|
311
|
+
res.write(Buffer.from(value));
|
|
312
|
+
}
|
|
313
|
+
res.end();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------- /api/music (ElevenLabs Music) ----------
|
|
317
|
+
async function handleMusic(req, res) {
|
|
318
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
319
|
+
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
320
|
+
const { prompt, durationMs } = await readJson(req);
|
|
321
|
+
if (!prompt) return send(res, 400, { error: 'нужен prompt' });
|
|
322
|
+
const body = { prompt };
|
|
323
|
+
if (durationMs) body.music_length_ms = +durationMs;
|
|
324
|
+
const r = await fetch(`${ELEVEN_BASE}/v1/music`, {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
|
|
327
|
+
body: JSON.stringify(body),
|
|
328
|
+
});
|
|
329
|
+
if (!r.ok) {
|
|
330
|
+
const t = await r.text();
|
|
331
|
+
return send(res, r.status, { error: t.slice(0, 400) });
|
|
332
|
+
}
|
|
333
|
+
res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
|
|
334
|
+
const reader = r.body.getReader();
|
|
335
|
+
while (true) {
|
|
336
|
+
const { done, value } = await reader.read();
|
|
337
|
+
if (done) break;
|
|
338
|
+
res.write(Buffer.from(value));
|
|
339
|
+
}
|
|
340
|
+
res.end();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------- /api/tts (ElevenLabs v3) ----------
|
|
344
|
+
async function handleTts(req, res) {
|
|
345
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
346
|
+
if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
|
|
347
|
+
const { text, voiceId = 'JBFqnCBsd6RMkjVDRZzb', modelId = 'eleven_v3' } = await readJson(req);
|
|
348
|
+
if (!text) return send(res, 400, { error: 'нужен text' });
|
|
349
|
+
const r = await fetch(`${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
|
|
352
|
+
body: JSON.stringify({ text, model_id: modelId }),
|
|
353
|
+
});
|
|
354
|
+
if (!r.ok) {
|
|
355
|
+
const t = await r.text();
|
|
356
|
+
return send(res, r.status, { error: t.slice(0, 300) });
|
|
357
|
+
}
|
|
358
|
+
res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
|
|
359
|
+
const reader = r.body.getReader();
|
|
360
|
+
while (true) {
|
|
361
|
+
const { done, value } = await reader.read();
|
|
362
|
+
if (done) break;
|
|
363
|
+
res.write(Buffer.from(value));
|
|
364
|
+
}
|
|
365
|
+
res.end();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------- статика ----------
|
|
369
|
+
async function serveStatic(res, url) {
|
|
370
|
+
let pathname = url.pathname === '/' ? '/index.html' : decodeURIComponent(url.pathname);
|
|
371
|
+
const filePath = normalize(join(ROOT, pathname));
|
|
372
|
+
if (!filePath.startsWith(ROOT)) return send(res, 403, { error: 'forbidden' });
|
|
373
|
+
try {
|
|
374
|
+
const s = await stat(filePath);
|
|
375
|
+
if (!s.isFile()) return send(res, 404, 'not found');
|
|
376
|
+
const data = await readFile(filePath);
|
|
377
|
+
const mime = MIME[extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
378
|
+
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-cache' });
|
|
379
|
+
res.end(data);
|
|
380
|
+
} catch {
|
|
381
|
+
send(res, 404, 'not found');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------- роутер ----------
|
|
386
|
+
const server = createServer(async (req, res) => {
|
|
387
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
388
|
+
try {
|
|
389
|
+
if (req.method === 'POST' && url.pathname === '/api/generate') return handleGenerate(req, res);
|
|
390
|
+
if (req.method === 'POST' && url.pathname === '/api/upload') return handleUpload(req, res);
|
|
391
|
+
if (req.method === 'GET' && url.pathname === '/api/poll') return handlePoll(res, url);
|
|
392
|
+
if (req.method === 'GET' && url.pathname === '/api/proxy') return handleProxy(res, url);
|
|
393
|
+
if (req.method === 'GET' && url.pathname === '/api/voices') return handleVoices(req, res);
|
|
394
|
+
if (req.method === 'POST' && url.pathname === '/api/tts') return handleTts(req, res);
|
|
395
|
+
if (req.method === 'POST' && url.pathname === '/api/sfx') return handleSfx(req, res);
|
|
396
|
+
if (req.method === 'POST' && url.pathname === '/api/music') return handleMusic(req, res);
|
|
397
|
+
if (req.method === 'POST' && url.pathname === '/api/text') return handleText(req, res);
|
|
398
|
+
if (req.method === 'GET') return serveStatic(res, url);
|
|
399
|
+
send(res, 404, 'not found');
|
|
400
|
+
} catch (e) {
|
|
401
|
+
console.error('[error]', e);
|
|
402
|
+
send(res, 500, { error: e.message || 'server error' });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Не падать на необработанных ошибках — логируем и живём дальше
|
|
407
|
+
process.on('unhandledRejection', (err) => console.error('[unhandledRejection]', err));
|
|
408
|
+
process.on('uncaughtException', (err) => console.error('[uncaughtException]', err));
|
|
409
|
+
|
|
410
|
+
function start(port = PORT) {
|
|
411
|
+
return new Promise((resolveOk, reject) => {
|
|
412
|
+
server.once('error', reject);
|
|
413
|
+
server.listen(port, () => {
|
|
414
|
+
const addr = server.address();
|
|
415
|
+
console.log(`▶ http://localhost:${addr.port}`);
|
|
416
|
+
console.log(` KIE_API_KEY: ${process.env.KIE_API_KEY ? '✓' : '✗ missing'}`);
|
|
417
|
+
console.log(` ELEVENLABS_API_KEY: ${process.env.ELEVENLABS_API_KEY ? '✓' : '✗ missing'}`);
|
|
418
|
+
resolveOk(addr.port);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (require.main === module) {
|
|
424
|
+
start();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = { start };
|