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 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 all = _ensureLoaded(pk);
130
- return all.slice(-limit);
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
- // 1) KIEесли useKie=true И KIE_API_KEY И модель поддерживается KIE
262
- // (юзер явно включил прямой коннектор используем его, экономим
263
- // Chatium-кредиты).
264
- // 2) Chatium fallback, либо если модель не входит в KIE (kling-3.0,
265
- // veo-3.1, runway и т.д.).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.18.15",
3
+ "version": "0.19.1",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
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).