kingkont 0.18.15 → 0.19.1
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/lib/eventStore.js +12 -2
- package/lib/providers.js +130 -5
- package/lib/settings.js +18 -0
- package/main.js +10 -0
- package/package.json +1 -1
- package/preload.js +5 -0
- package/renderer/board.js +256 -101
- package/renderer/cloudFs.js +8 -0
- package/renderer/cloudProjects.js +40 -5
- package/renderer/generate.js +4 -1
- package/renderer/notifyPanel.js +53 -37
- package/renderer/state.js +5 -0
- package/renderer/styles.css +31 -2
- package/server.js +180 -2
package/lib/eventStore.js
CHANGED
|
@@ -126,8 +126,18 @@ function append(projectKey, event) {
|
|
|
126
126
|
|
|
127
127
|
function list(projectKey, { limit = MAX_PER_PROJECT } = {}) {
|
|
128
128
|
const pk = projectKey || '_global';
|
|
129
|
-
const
|
|
130
|
-
|
|
129
|
+
const own = _ensureLoaded(pk).slice();
|
|
130
|
+
// Backward-compat: события могли быть mis-filed в _global (когда юзер
|
|
131
|
+
// на welcome генерил, _persistEvent.projectKey был null). Извлекаем
|
|
132
|
+
// те у которых target.projectKey совпадает с запрошенным.
|
|
133
|
+
if (pk !== '_global') {
|
|
134
|
+
const global = _ensureLoaded('_global');
|
|
135
|
+
for (const e of global) {
|
|
136
|
+
if (e.target?.projectKey === pk) own.push({ ...e });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
own.sort((a, b) => a.ts - b.ts);
|
|
140
|
+
return own.slice(-limit);
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
// Для welcome — все события из всех проектов, отсортированные по ts.
|
package/lib/providers.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
'use strict';
|
|
11
11
|
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
|
|
12
14
|
const KIE_BASE = 'https://api.kie.ai';
|
|
13
15
|
const ELEVEN_BASE = 'https://api.elevenlabs.io';
|
|
14
16
|
|
|
@@ -93,6 +95,12 @@ const CHATIUM_VIDEO_MODELS = {
|
|
|
93
95
|
'grok-t2v': 'grok-imagine/text-to-video',
|
|
94
96
|
};
|
|
95
97
|
|
|
98
|
+
// OpenRouter image-gen модели. Раньше пробовали google/gemini-2.5-flash-image-preview,
|
|
99
|
+
// но OpenRouter отдаёт 404 «No endpoints found» — slug устарел / модель за paywall.
|
|
100
|
+
// Пока пусто = роутинг через OpenRouter не активен → fall through на KIE / Chatium.
|
|
101
|
+
// Если найдётся рабочий slug — вернём.
|
|
102
|
+
const OPENROUTER_IMAGE_MODELS = {};
|
|
103
|
+
|
|
96
104
|
function resolveModel(map, key, fallback) {
|
|
97
105
|
if (!key) return fallback;
|
|
98
106
|
return map[key] || (Object.values(map).includes(key) ? key : null);
|
|
@@ -258,12 +266,20 @@ async function startGeneration(args) {
|
|
|
258
266
|
if (!prompt && kind !== 'video') throw new Error('prompt обязателен');
|
|
259
267
|
|
|
260
268
|
// Приоритет провайдеров:
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
269
|
+
// 0) OpenRouter — для image-моделей которые он умеет (nano-banana-*).
|
|
270
|
+
// Юзер: «если включен openrouter — отправлять задачу на генерацию
|
|
271
|
+
// изображения в openrouter, а не в kie». Экономим KIE-кредиты,
|
|
272
|
+
// OpenRouter обычно дешевле для image-gen.
|
|
273
|
+
// 1) KIE — если useKie=true И KIE_API_KEY И модель поддерживается KIE.
|
|
274
|
+
// 2) Chatium — fallback, либо если модель не входит в KIE.
|
|
266
275
|
// 3) Если ни один не доступен — ошибка.
|
|
276
|
+
const orKey = modelKey || (kind === 'image' ? 'nano-banana-2' : null);
|
|
277
|
+
const orAvailable = s.useOpenrouter && process.env.OPENROUTER_API_KEY;
|
|
278
|
+
const orSupportsModel = kind === 'image' && !!OPENROUTER_IMAGE_MODELS[orKey];
|
|
279
|
+
if (orAvailable && orSupportsModel) {
|
|
280
|
+
return await _startGenerationViaOpenRouter({ key: orKey, prompt, imageInputs, aspectRatio });
|
|
281
|
+
}
|
|
282
|
+
|
|
267
283
|
const kieMap = kind === 'video' ? KIE_VIDEO_MODELS : KIE_IMAGE_MODELS;
|
|
268
284
|
const kieKey = modelKey || (kind === 'video' ? 'seedance-2' : 'nano-banana-2');
|
|
269
285
|
const kieAvailable = s.useKie && process.env.KIE_API_KEY;
|
|
@@ -311,6 +327,106 @@ async function startGeneration(args) {
|
|
|
311
327
|
: 'Войдите в KingKont или KIE для картинок.');
|
|
312
328
|
}
|
|
313
329
|
|
|
330
|
+
// In-memory store sync-результатов OpenRouter image-gen. taskId →
|
|
331
|
+
// {status, url, error, cost, ts}. pollGeneration читает отсюда для
|
|
332
|
+
// `openrouter:` префиксов. GC через 1 час чтобы не накапливать.
|
|
333
|
+
const _openrouterResults = new Map();
|
|
334
|
+
function _gcOpenrouterResults() {
|
|
335
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
336
|
+
for (const [k, v] of _openrouterResults.entries()) {
|
|
337
|
+
if (v.ts < cutoff) _openrouterResults.delete(k);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// OpenRouter image-gen — sync-вызов через chat completions. Модель
|
|
342
|
+
// возвращает изображение как data URL в `message.images[]` ИЛИ в
|
|
343
|
+
// `message.content` как content-block. Чтобы вписаться в наш taskId+poll
|
|
344
|
+
// API, выполняем СИНХРОННО, сохраняем результат в _openrouterResults,
|
|
345
|
+
// возвращаем синтетический taskId. pollGeneration сразу отдаст done.
|
|
346
|
+
async function _startGenerationViaOpenRouter({ key, prompt, imageInputs, aspectRatio }) {
|
|
347
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
348
|
+
const model = OPENROUTER_IMAGE_MODELS[key];
|
|
349
|
+
if (!model) throw new Error(`OpenRouter не поддерживает модель "${key}"`);
|
|
350
|
+
// Собираем content. Если есть imageInputs → multimodal user-msg
|
|
351
|
+
// (image-to-image / edit mode). Иначе чистый prompt.
|
|
352
|
+
const userContent = [{ type: 'text', text: prompt }];
|
|
353
|
+
for (const url of (imageInputs || [])) {
|
|
354
|
+
if (!url) continue;
|
|
355
|
+
userContent.push({ type: 'image_url', image_url: { url } });
|
|
356
|
+
}
|
|
357
|
+
const body = {
|
|
358
|
+
model,
|
|
359
|
+
modalities: ['image', 'text'], // обязательно — иначе модель отдаст только текст
|
|
360
|
+
messages: [{ role: 'user', content: userContent.length > 1 ? userContent : prompt }],
|
|
361
|
+
};
|
|
362
|
+
// aspectRatio не маппится напрямую (OpenRouter Gemini Flash Image не имеет
|
|
363
|
+
// явного параметра) — добавляем в prompt как hint. Грубо, но работает.
|
|
364
|
+
if (aspectRatio && aspectRatio !== '1:1') {
|
|
365
|
+
body.messages[0].content = (typeof body.messages[0].content === 'string')
|
|
366
|
+
? `${body.messages[0].content} (aspect ratio ${aspectRatio})`
|
|
367
|
+
: [...body.messages[0].content, { type: 'text', text: `(aspect ratio ${aspectRatio})` }];
|
|
368
|
+
}
|
|
369
|
+
const taskId = 'openrouter:' + crypto.randomUUID();
|
|
370
|
+
// Регистрируем как pending — на случай очень быстрого poll.
|
|
371
|
+
_openrouterResults.set(taskId, { status: 'pending', ts: Date.now() });
|
|
372
|
+
// Файр-энд-фогет, т.к. мы возвращаем taskId сразу. Промис обновит результат.
|
|
373
|
+
(async () => {
|
|
374
|
+
logCall('POST', 'OpenRouter', 'https://openrouter.ai/api/v1/chat/completions',
|
|
375
|
+
`model=${model} kind=image promptLen=${prompt.length}ch refs=${imageInputs?.length||0}`);
|
|
376
|
+
try {
|
|
377
|
+
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
headers: {
|
|
380
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
381
|
+
'Content-Type': 'application/json',
|
|
382
|
+
'HTTP-Referer': 'http://localhost',
|
|
383
|
+
'X-Title': 'KingKont',
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify(body),
|
|
386
|
+
});
|
|
387
|
+
const text = await r.text();
|
|
388
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
389
|
+
if (!r.ok) {
|
|
390
|
+
const baseMsg = data?.error?.message || data?.raw || `HTTP ${r.status}`;
|
|
391
|
+
const meta = data?.error?.metadata || {};
|
|
392
|
+
const provName = meta.provider_name ? ` [${meta.provider_name}]` : '';
|
|
393
|
+
const extra = meta.raw ? ` — ${(typeof meta.raw === 'string' ? meta.raw : JSON.stringify(meta.raw)).slice(0, 400)}` : '';
|
|
394
|
+
throw new Error(`${baseMsg}${provName}${extra}`);
|
|
395
|
+
}
|
|
396
|
+
// Парсим image. OpenRouter Gemini Flash Image возвращает images
|
|
397
|
+
// в `choices[0].message.images[]` (массив content-block'ов с
|
|
398
|
+
// image_url.url = data:image/...;base64,...).
|
|
399
|
+
const msg = data?.choices?.[0]?.message || {};
|
|
400
|
+
let imageUrl = null;
|
|
401
|
+
// Form 1: `images` array.
|
|
402
|
+
if (Array.isArray(msg.images) && msg.images.length) {
|
|
403
|
+
imageUrl = msg.images[0]?.image_url?.url || msg.images[0]?.url || null;
|
|
404
|
+
}
|
|
405
|
+
// Form 2: content array with image_url block.
|
|
406
|
+
if (!imageUrl && Array.isArray(msg.content)) {
|
|
407
|
+
for (const block of msg.content) {
|
|
408
|
+
if (block?.type === 'image_url' && block.image_url?.url) {
|
|
409
|
+
imageUrl = block.image_url.url;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!imageUrl) {
|
|
415
|
+
// Лог для дебага — может Gemini отдал в другом поле или вообще не
|
|
416
|
+
// в этом формате.
|
|
417
|
+
console.warn('[openrouter image] no image found in response:', JSON.stringify(data).slice(0, 500));
|
|
418
|
+
throw new Error('OpenRouter не вернул изображение');
|
|
419
|
+
}
|
|
420
|
+
_openrouterResults.set(taskId, { status: 'done', url: imageUrl, cost: null, ts: Date.now() });
|
|
421
|
+
} catch (e) {
|
|
422
|
+
console.warn('[openrouter image] failed:', e?.message || e);
|
|
423
|
+
_openrouterResults.set(taskId, { status: 'error', error: e?.message || String(e), ts: Date.now() });
|
|
424
|
+
}
|
|
425
|
+
_gcOpenrouterResults();
|
|
426
|
+
})();
|
|
427
|
+
return { taskId, provider: 'openrouter' };
|
|
428
|
+
}
|
|
429
|
+
|
|
314
430
|
// Внутренний helper: KIE-путь startGeneration. Вынесен чтобы не дублировать
|
|
315
431
|
// логику между «KIE первичный» и старым «KIE fallback».
|
|
316
432
|
async function _startGenerationViaKie({ kind, prompt, key, imageInputs, videoInputs, aspectRatio, resolution, duration, quality }) {
|
|
@@ -369,6 +485,15 @@ async function _startGenerationViaKie({ kind, prompt, key, imageInputs, videoInp
|
|
|
369
485
|
* @returns {Promise<{ status: 'done'|'pending'|'error', url?: string, error?: string, cost?: number, state?: string, provider: string }>}
|
|
370
486
|
*/
|
|
371
487
|
async function pollGeneration(taskId, settings) {
|
|
488
|
+
// OpenRouter image-gen sync — берём результат из in-memory store.
|
|
489
|
+
if (taskId.startsWith('openrouter:')) {
|
|
490
|
+
const r = _openrouterResults.get(taskId);
|
|
491
|
+
if (!r) return { status: 'error', error: 'taskId не найден (TTL истёк или server рестартанул)', provider: 'openrouter' };
|
|
492
|
+
if (r.status === 'done') return { status: 'done', url: r.url, cost: r.cost ?? null, provider: 'openrouter' };
|
|
493
|
+
if (r.status === 'error') return { status: 'error', error: r.error, provider: 'openrouter' };
|
|
494
|
+
return { status: 'pending', state: 'generating', provider: 'openrouter' };
|
|
495
|
+
}
|
|
496
|
+
|
|
372
497
|
if (taskId.startsWith('chatium:')) {
|
|
373
498
|
const realId = taskId.slice('chatium:'.length);
|
|
374
499
|
if (!settings.chatium?.token) return { status: 'error', error: 'Нет Chatium-сессии', provider: 'kingkont' };
|
package/lib/settings.js
CHANGED
|
@@ -50,6 +50,23 @@ function loadSettings(p = defaultSettingsPath()) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Записать settings.json (атомарно через tmp+rename).
|
|
55
|
+
* Используется server.js для web-flow логина (записать chatium.token).
|
|
56
|
+
*/
|
|
57
|
+
function writeSettings(s, p = defaultSettingsPath()) {
|
|
58
|
+
try {
|
|
59
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
60
|
+
const tmp = p + '.tmp';
|
|
61
|
+
fs.writeFileSync(tmp, JSON.stringify(s || {}, null, 2), 'utf-8');
|
|
62
|
+
fs.renameSync(tmp, p);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.warn('[settings] write failed:', e?.message || e);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
/**
|
|
54
71
|
* Прокидывает прямые API-ключи в process.env, если они есть.
|
|
55
72
|
* server.js / lib/providers ожидают KIE_API_KEY / ELEVENLABS_API_KEY /
|
|
@@ -66,5 +83,6 @@ function applySettingsToEnv(s) {
|
|
|
66
83
|
module.exports = {
|
|
67
84
|
defaultSettingsPath,
|
|
68
85
|
loadSettings,
|
|
86
|
+
writeSettings,
|
|
69
87
|
applySettingsToEnv,
|
|
70
88
|
};
|
package/main.js
CHANGED
|
@@ -628,6 +628,16 @@ ipcMain.handle('updates:install', async (e, target = 'latest') => {
|
|
|
628
628
|
|
|
629
629
|
ipcMain.handle('app:version', () => app.getVersion());
|
|
630
630
|
|
|
631
|
+
// Cold-start flag: true только на первый запрос за app-launch. Cmd+R
|
|
632
|
+
// (renderer reload) → false. Renderer использует чтобы при cold-start
|
|
633
|
+
// показать welcome (а не autoload последнего проекта).
|
|
634
|
+
let _coldStartConsumed = false;
|
|
635
|
+
ipcMain.handle('app:consume-cold-start', () => {
|
|
636
|
+
if (_coldStartConsumed) return false;
|
|
637
|
+
_coldStartConsumed = true;
|
|
638
|
+
return true;
|
|
639
|
+
});
|
|
640
|
+
|
|
631
641
|
ipcMain.handle('app:relaunch', () => {
|
|
632
642
|
// app.relaunch() запускает ТЕКУЩИЙ execPath. Если юзер обновился через
|
|
633
643
|
// `npm i -g`, новая kingkont лежит в global-bin, а не в npx-кэше где сидит
|
package/package.json
CHANGED
package/preload.js
CHANGED
|
@@ -38,6 +38,11 @@ contextBridge.exposeInMainWorld('appInfo', {
|
|
|
38
38
|
|
|
39
39
|
contextBridge.exposeInMainWorld('appProject', {
|
|
40
40
|
notifyState: (isOpen) => ipcRenderer.send('project:state', !!isOpen),
|
|
41
|
+
// Возвращает true ровно ОДИН раз за app-launch — на самом первом
|
|
42
|
+
// GET'е из renderer'а. Cmd+R (reload renderer) → возвращает false.
|
|
43
|
+
// Используется чтобы при cold-start показывать welcome, а при reload —
|
|
44
|
+
// оставаться в проекте (autoload последнего recent).
|
|
45
|
+
consumeColdStart: () => ipcRenderer.invoke('app:consume-cold-start'),
|
|
41
46
|
});
|
|
42
47
|
|
|
43
48
|
// Recents — JSON-файл в userData (переживает любой перезапуск, даже kill -9).
|