kingkont 0.20.3 → 0.20.5

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/providers.js CHANGED
@@ -31,8 +31,13 @@ const CHATIUM_PATHS = {
31
31
  // Cloud-projects (НЕ шаблоны) — редактируемые проекты юзера на сервере.
32
32
  // Owner-only видимость, save-to-server по explicit-кнопке.
33
33
  // Формат:
34
- // { id, name, manifest: { boards: [{ kind, name, scene, files: {relPath: cdnUrl} }] }, files, coverUrl }
34
+ // { id, name, manifest: { boards: [{ kind, name, scene, files: {relPath: cdnUrl}, texts: {relPath: textId} }] }, files, coverUrl }
35
35
  projects: '/app/spaces/server/api/projects',
36
+ // Текстовый контент (.md) проектов — отдельная таблица t_spaces_server_project_texts_K1.
37
+ // Manifest проекта ссылается на записи по id. Зачем: Chatium-storage даёт
38
+ // 502 на text/markdown; CDN-roundtrip оверхед для маленьких файлов;
39
+ // изменение текста = UPDATE существующей записи, без orphan'ов в storage.
40
+ projectTexts: '/app/spaces/server/api/project_texts',
36
41
  };
37
42
 
38
43
  const CHATIUM_CDN = 'https://fs.chatium.ru/get';
@@ -1144,6 +1149,59 @@ async function deleteProject(id, s) {
1144
1149
  return true;
1145
1150
  }
1146
1151
 
1152
+ // ---- Project texts (.md содержимое text-нод). ------------------------------
1153
+ // Mirror of project CRUD. Хранятся в отдельной таблице чтобы не раздувать
1154
+ // manifest и обходить 502 на text/markdown в Chatium-storage.
1155
+
1156
+ async function createProjectText(body, s) {
1157
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
1158
+ logCall('POST', 'Chatium', chatiumBase(s) + CHATIUM_PATHS.projectTexts,
1159
+ `projectId=${body?.projectId} relPath=${body?.relPath} len=${body?.content?.length||0}`);
1160
+ const r = await fetch(chatiumBase(s) + CHATIUM_PATHS.projectTexts, {
1161
+ method: 'POST', headers, body: JSON.stringify(body),
1162
+ });
1163
+ const text = await r.text();
1164
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
1165
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
1166
+ return d;
1167
+ }
1168
+
1169
+ async function getProjectText(id, s) {
1170
+ const headers = chatiumAuthHeaders(s);
1171
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.projectTexts}~get?id=${encodeURIComponent(id)}`;
1172
+ logCall('GET ', 'Chatium', url);
1173
+ const r = await fetch(url, { headers });
1174
+ const text = await r.text();
1175
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
1176
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
1177
+ return d;
1178
+ }
1179
+
1180
+ async function updateProjectText(id, body, s) {
1181
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
1182
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.projectTexts}~update?id=${encodeURIComponent(id)}`;
1183
+ logCall('POST', 'Chatium', url, `len=${body?.content?.length||0}`);
1184
+ const r = await fetch(url, {
1185
+ method: 'POST', headers, body: JSON.stringify(body),
1186
+ });
1187
+ const text = await r.text();
1188
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
1189
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
1190
+ return d;
1191
+ }
1192
+
1193
+ async function deleteProjectText(id, s) {
1194
+ const headers = chatiumAuthHeaders(s);
1195
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.projectTexts}~delete?id=${encodeURIComponent(id)}`;
1196
+ logCall('POST', 'Chatium', url);
1197
+ const r = await fetch(url, { method: 'POST', headers });
1198
+ if (!r.ok) {
1199
+ const text = await r.text().catch(() => '');
1200
+ throw new Error(text || `HTTP ${r.status}`);
1201
+ }
1202
+ return true;
1203
+ }
1204
+
1147
1205
  async function listElevenVoices() {
1148
1206
  const key = process.env.ELEVENLABS_API_KEY;
1149
1207
  if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
@@ -1190,6 +1248,11 @@ module.exports = {
1190
1248
  createProject,
1191
1249
  updateProject,
1192
1250
  deleteProject,
1251
+ // Project texts (.md контент text-нод — отдельная таблица)
1252
+ createProjectText,
1253
+ getProjectText,
1254
+ updateProjectText,
1255
+ deleteProjectText,
1193
1256
  // Constants (для server.js / тестов)
1194
1257
  KIE_IMAGE_MODELS,
1195
1258
  KIE_VIDEO_MODELS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -296,5 +296,17 @@
296
296
  await backend.ensure();
297
297
  return backend.write(relPath, data);
298
298
  },
299
+ async readFile(projectId, relPath) {
300
+ const backend = makeBackend(projectId);
301
+ return backend.read(relPath);
302
+ },
303
+ async readTextFile(projectId, relPath) {
304
+ const backend = makeBackend(projectId);
305
+ try { return await backend.readText(relPath); } catch { return null; }
306
+ },
307
+ async existsFile(projectId, relPath) {
308
+ const backend = makeBackend(projectId);
309
+ return backend.exists(relPath);
310
+ },
299
311
  };
300
312
  })();
@@ -257,14 +257,22 @@
257
257
  } catch {}
258
258
  }
259
259
 
260
- // 1. Проверяем синхронизирован ли уже в userData/cloud-projects/<id>/.
261
- if (window.cloudFs) {
260
+ // 1. Проверяем синхронизирован ли уже локально.
261
+ // Electron: userData/cloud-projects/<id>/ через window.cloudFs (диск).
262
+ // Web: in-memory map в cloudFsShim — живёт пока страница не перезагружена.
263
+ // Без web-ветки браузер качал файлы при каждом open, хотя в памяти
264
+ // они уже лежали с прошлого визита в этой же сессии.
265
+ if (!opts.forceRefresh) {
262
266
  try {
263
- const metaText = await window.cloudFs.readText(projectId, '.kingkont-meta.json');
264
- if (metaText && !opts.forceRefresh) {
267
+ let metaText = null;
268
+ if (window.cloudFs) {
269
+ metaText = await window.cloudFs.readText(projectId, '.kingkont-meta.json');
270
+ } else if (window.cloudFsShim?.readTextFile) {
271
+ metaText = await window.cloudFsShim.readTextFile(projectId, '.kingkont-meta.json');
272
+ }
273
+ if (metaText) {
265
274
  const meta = JSON.parse(metaText);
266
275
  if (meta.cloudProjectId === projectId) {
267
- // Local copy готова — открываем без скачивания.
268
276
  const handle = await window.cloudFsShim.makeCloudHandle(projectId, suggestedName || projectId);
269
277
  state.cloudProjectId = projectId;
270
278
  state.cloudDirty = false;
@@ -288,13 +296,15 @@
288
296
  const handle = await window.cloudFsShim.makeCloudHandle(projectId, proj.name || suggestedName || 'Project');
289
297
  // Скачиваем все board'ы манифеста (если они уже не лежат локально с тем же hash).
290
298
  const boards = Array.isArray(proj.manifest?.boards) ? proj.manifest.boards : [];
291
- // Сначала собираем общий список файлов для прогресса.
292
- const totalFiles = boards.reduce((s, b) => s + Object.keys(b?.files || {}).length, 0);
299
+ // Сначала собираем общий список файлов для прогресса (CDN-файлы + DB-тексты).
300
+ const totalFiles = boards.reduce(
301
+ (s, b) => s + Object.keys(b?.files || {}).length + Object.keys(b?.texts || {}).length, 0);
293
302
  let done = 0;
294
- // fileHashes индексируем тем же ключом что использует save: `${kind}/${name}/${relPath}`.
295
- // Это критично — иначе при следующем save dedup не найдёт ничего и
296
- // перезальёт все файлы заново.
303
+ // fileHashes / textHashes индексируем тем же ключом что использует save:
304
+ // `${kind}/${name}/${relPath}`. Иначе при следующем save dedup не найдёт
305
+ // ничего и перезальёт всё заново.
297
306
  const fileHashes = {};
307
+ const textHashes = {};
298
308
  for (const board of boards) {
299
309
  const boardDir = boardKindToDir(board.kind, board.name);
300
310
  // 1) scene.json
@@ -302,7 +312,7 @@
302
312
  await writeCloudFile(projectId, joinPath(boardDir, 'scene.json'),
303
313
  new TextEncoder().encode(JSON.stringify(board.scene, null, 2)));
304
314
  }
305
- // 2) media-файлы
315
+ // 2) media-файлы — из CDN.
306
316
  for (const [relPath, cdnUrl] of Object.entries(board.files || {})) {
307
317
  done++;
308
318
  PROGRESS.update(done, totalFiles, `Загрузка ${relPath} (${done}/${totalFiles})`);
@@ -324,6 +334,27 @@
324
334
  url: cdnUrl, size: buf.byteLength, mtime,
325
335
  };
326
336
  }
337
+ // 3) text-файлы (.md) — из таблицы project_texts по textId.
338
+ for (const [relPath, textId] of Object.entries(board.texts || {})) {
339
+ done++;
340
+ PROGRESS.update(done, totalFiles, `Загрузка ${relPath} (${done}/${totalFiles})`);
341
+ const tr = await fetch('/api/project-texts/' + encodeURIComponent(textId));
342
+ if (!tr.ok) throw new Error(`Не удалось получить текст ${relPath}: ${tr.status}`);
343
+ const td = await tr.json();
344
+ const buf = new TextEncoder().encode(td.content || '');
345
+ const localPath = joinPath(boardDir, relPath);
346
+ await writeCloudFile(projectId, localPath, buf);
347
+ let mtime = Date.now();
348
+ if (window.cloudFs?.stat) {
349
+ try {
350
+ const st = await window.cloudFs.stat(projectId, localPath);
351
+ if (st?.mtimeMs) mtime = st.mtimeMs;
352
+ } catch {}
353
+ }
354
+ textHashes[`${board.kind}/${board.name}/${relPath}`] = {
355
+ textId, size: buf.byteLength, mtime,
356
+ };
357
+ }
327
358
  }
328
359
  // 3) cover (опционально). Запоминаем size+mtime → coverHash для
329
360
  // dedup'а при следующем save'е (иначе первый же save перезальёт
@@ -356,6 +387,7 @@
356
387
  new TextEncoder().encode(JSON.stringify({
357
388
  cloudProjectId: projectId,
358
389
  fileHashes,
390
+ textHashes,
359
391
  coverUrl: proj.coverUrl || null,
360
392
  coverHash: openedCoverHash,
361
393
  syncedAt: Date.now(),
@@ -432,7 +464,9 @@
432
464
  ? (await readProjectMeta(state.filmHandle)) || {}
433
465
  : {};
434
466
  const oldHashes = meta.fileHashes || {};
467
+ const oldTextHashes = meta.textHashes || {};
435
468
  const newHashes = {};
469
+ const newTextHashes = {};
436
470
 
437
471
  // 1) Собираем все board'ы локального проекта (та же логика что у
438
472
  // saveCurrentProjectAsTemplate из templates.js).
@@ -451,6 +485,7 @@
451
485
  try { scene = JSON.parse(text); } catch {}
452
486
  }
453
487
  const boardFiles = {};
488
+ const boardTexts = {};
454
489
  // walkBoardFiles возвращает {relPath, fileHandle}.
455
490
  for (const f of board.mediaFiles) {
456
491
  if (!f.fileHandle) {
@@ -459,6 +494,73 @@
459
494
  }
460
495
  const file = await f.fileHandle.getFile();
461
496
  const metaKey = `${board.kind}/${board.name}/${f.relPath}`;
497
+ const isMd = /\.md$/i.test(f.relPath);
498
+
499
+ // ---- .md → DB-таблица project_texts (не CDN-storage). ----
500
+ // На стороне клиента грейсфул-fallback: если эндпоинт ещё не задеплоен
501
+ // (HTTP 404 → "is not found in UgcPluginEntity"), .md уходит по
502
+ // старому пути через CDN. После деплоя сервера новый путь активируется
503
+ // автоматически, без обновления клиента.
504
+ if (isMd) {
505
+ const cachedText = oldTextHashes[metaKey];
506
+ // Dedup: size+mtime совпадают → переиспользуем textId, нет API-вызова.
507
+ if (cachedText && cachedText.size === file.size && cachedText.mtime === file.lastModified && cachedText.textId) {
508
+ boardTexts[f.relPath] = cachedText.textId;
509
+ newTextHashes[metaKey] = cachedText;
510
+ reused++;
511
+ uploaded++;
512
+ PROGRESS.update(uploaded, total,
513
+ `[${board.kind}/${board.name}] ${f.relPath} (cached, ${uploaded}/${total})`);
514
+ continue;
515
+ }
516
+ PROGRESS.update(uploaded, total,
517
+ `[${board.kind}/${board.name}] ${f.relPath} (text, ${uploaded + 1}/${total})`);
518
+ const content = await file.text();
519
+ let textId = cachedText?.textId || null;
520
+ let dbPathFailed = false;
521
+ if (textId) {
522
+ // UPDATE существующей записи — id остаётся прежним.
523
+ const ur = await fetch('/api/project-texts/' + encodeURIComponent(textId), {
524
+ method: 'POST',
525
+ headers: { 'Content-Type': 'application/json' },
526
+ body: JSON.stringify({ content, relPath: f.relPath }),
527
+ });
528
+ if (!ur.ok) {
529
+ if (ur.status === 404) {
530
+ // Либо id протух, либо эндпоинт ещё не задеплоен — пробуем create.
531
+ textId = null;
532
+ } else {
533
+ throw new Error(`update text ${f.relPath} failed: ${ur.status}`);
534
+ }
535
+ }
536
+ }
537
+ if (!textId) {
538
+ const cr = await fetch('/api/project-texts', {
539
+ method: 'POST',
540
+ headers: { 'Content-Type': 'application/json' },
541
+ body: JSON.stringify({ projectId, relPath: f.relPath, content }),
542
+ });
543
+ if (cr.ok) {
544
+ const j = await cr.json();
545
+ textId = j.id;
546
+ } else if (cr.status === 404 || cr.status === 502) {
547
+ // Эндпоинт ещё не задеплоен на chatium-стороне → fallback на CDN.
548
+ console.warn(`[cloudProjects] /api/project-texts ${cr.status} → fallback на CDN-upload для ${f.relPath}`);
549
+ dbPathFailed = true;
550
+ } else {
551
+ throw new Error(`create text ${f.relPath} failed: ${cr.status}`);
552
+ }
553
+ }
554
+ if (textId) {
555
+ boardTexts[f.relPath] = textId;
556
+ newTextHashes[metaKey] = { textId, size: file.size, mtime: file.lastModified };
557
+ uploaded++;
558
+ continue;
559
+ }
560
+ // dbPathFailed → проваливаемся в общий CDN-upload ниже.
561
+ }
562
+
563
+ // ---- Бинарь → CDN через /api/upload. ----
462
564
  const cached = oldHashes[metaKey];
463
565
  // Dedup: size + mtime совпадают → переиспользуем CDN-URL.
464
566
  // (Это identifier «файл не менялся с момента последней синхронизации»;
@@ -490,7 +592,9 @@
490
592
  newHashes[metaKey] = { url: j.url, size: file.size, mtime: file.lastModified };
491
593
  uploaded++;
492
594
  }
493
- manifestBoards.push({ kind: board.kind, name: board.name, scene, files: boardFiles });
595
+ const boardEntry = { kind: board.kind, name: board.name, scene, files: boardFiles };
596
+ if (Object.keys(boardTexts).length) boardEntry.texts = boardTexts;
597
+ manifestBoards.push(boardEntry);
494
598
  }
495
599
 
496
600
  // 2) Cover (dedup'нутый — не грузим если size+mtime совпадают).
@@ -547,6 +651,7 @@
547
651
  ...meta,
548
652
  cloudProjectId: projectId,
549
653
  fileHashes: newHashes,
654
+ textHashes: newTextHashes,
550
655
  coverUrl: coverUrl || null,
551
656
  coverHash: newCoverHash,
552
657
  syncedAt: Date.now(),
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // Тест: использует ли gpt-image-2-image-to-image референсное изображение?
3
+ //
4
+ // Делаем 2 генерации с одинаковым промптом:
5
+ // 1) text-only ("control") — gpt-image-2-text-to-image
6
+ // 2) image-to-image — gpt-image-2-image-to-image + reference URL
7
+ //
8
+ // Если референс реально работает — результат #2 будет иметь композицию/
9
+ // объект из reference. Если игнорируется — результаты будут похожи между собой.
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+ const { loadSettings, applySettingsToEnv } = require('../lib/settings');
16
+ const { startGeneration, waitForGeneration, uploadFile, downloadUrl } = require('../lib/providers');
17
+
18
+ // Референс: фото конкретного полосатого котика (Wikipedia, public).
19
+ // Если модель его «видит» — на выходе должен быть похожий кот (та же поза,
20
+ // тот же фон), просто в новом цвете. Если игнорирует — выйдет
21
+ // generic-кот на generic-фоне.
22
+ const REF_URL = 'https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg';
23
+ const PROMPT = 'recolor this exact cat to be completely white, keep the same pose, background, framing, and scene, photorealistic';
24
+
25
+ (async () => {
26
+ const s = loadSettings();
27
+ applySettingsToEnv(s);
28
+ console.log('[test] useChatium=%s hasToken=%s base=%s', s.useChatium, !!s.chatium?.token, s.chatium?.base);
29
+
30
+ // 1) Скачать референс с Wikipedia, загрузить в Chatium-storage.
31
+ console.log('[test] download ref:', REF_URL);
32
+ const refBuf = await downloadUrl(REF_URL);
33
+ console.log('[test] ref size:', refBuf.length, 'bytes');
34
+ const up = await uploadFile({ buffer: refBuf, filename: 'cat-ref.jpg', mime: 'image/jpeg', settings: s });
35
+ console.log('[test] uploaded ref →', up.url);
36
+
37
+ // 2) Параллельно: text-only (control) + image-to-image (with ref).
38
+ console.log('[test] kick off 2 generations...');
39
+ const opts = { kind: 'image', modelKey: 'gpt-image-2', prompt: PROMPT, aspectRatio: '1:1', settings: s };
40
+ const [ctrlStart, refStart] = await Promise.all([
41
+ startGeneration({ ...opts }),
42
+ startGeneration({ ...opts, imageInputs: [up.url] }),
43
+ ]);
44
+ console.log('[test] control task:', ctrlStart.taskId);
45
+ console.log('[test] withref task:', refStart.taskId);
46
+
47
+ const [ctrlRes, refRes] = await Promise.all([
48
+ waitForGeneration(ctrlStart.taskId, s, { timeoutMs: 240000, onProgress: (r) => console.log(' control:', r.state || r.status) }),
49
+ waitForGeneration(refStart.taskId, s, { timeoutMs: 240000, onProgress: (r) => console.log(' withref:', r.state || r.status) }),
50
+ ]);
51
+
52
+ console.log('\n[test] === RESULTS ===');
53
+ console.log('CONTROL:', JSON.stringify(ctrlRes, null, 2));
54
+ console.log('WITH REF:', JSON.stringify(refRes, null, 2));
55
+
56
+ // Скачать оба результата, чтобы можно было визуально сравнить.
57
+ const outDir = '/tmp/kk-gptimage-ref-test';
58
+ fs.mkdirSync(outDir, { recursive: true });
59
+ fs.writeFileSync(path.join(outDir, 'reference.jpg'), refBuf);
60
+ for (const [name, res] of [['control', ctrlRes], ['withref', refRes]]) {
61
+ const url = res?.url || res?.result?.url || res?.imageUrl || res?.images?.[0];
62
+ if (!url) { console.warn('[test] no url for', name); continue; }
63
+ const buf = await downloadUrl(url);
64
+ const out = path.join(outDir, `${name}.jpg`);
65
+ fs.writeFileSync(out, buf);
66
+ console.log(`[test] saved ${name}: ${out} (${buf.length} bytes, src=${url})`);
67
+ }
68
+ console.log('\n[test] open with: open ' + outDir);
69
+ })().catch(e => { console.error('[test] FAILED:', e); process.exit(1); });
package/server.js CHANGED
@@ -462,6 +462,35 @@ async function handleProjectDelete(res, id) {
462
462
  } catch (e) { sendError(res, e, 502); }
463
463
  }
464
464
 
465
+ // ---- Project texts (.md контент text-нод). --------------------------------
466
+ // Зачем отдельный API: см. lib/providers.js → CHATIUM_PATHS.projectTexts.
467
+
468
+ async function handleProjectTextCreate(req, res) {
469
+ try {
470
+ const body = await readJson(req); // { projectId, relPath, content }
471
+ send(res, 200, await providers.createProjectText(body, getSettings()));
472
+ } catch (e) { sendError(res, e, 502); }
473
+ }
474
+
475
+ async function handleProjectTextGet(res, id) {
476
+ try { send(res, 200, await providers.getProjectText(id, getSettings())); }
477
+ catch (e) { sendError(res, e, 502); }
478
+ }
479
+
480
+ async function handleProjectTextUpdate(req, res, id) {
481
+ try {
482
+ const body = await readJson(req); // { content?, relPath? }
483
+ send(res, 200, await providers.updateProjectText(id, body, getSettings()));
484
+ } catch (e) { sendError(res, e, 502); }
485
+ }
486
+
487
+ async function handleProjectTextDelete(res, id) {
488
+ try {
489
+ await providers.deleteProjectText(id, getSettings());
490
+ send(res, 200, { ok: true });
491
+ } catch (e) { sendError(res, e, 502); }
492
+ }
493
+
465
494
  // =============================================================================
466
495
  // Chat sessions: server-side LLM-loop с tool-pump pattern.
467
496
  // Клиент: POST /api/chat/send {key, text, system} → starts loop
@@ -686,6 +715,17 @@ async function _requestHandler(req, res) {
686
715
  if (req.method === 'DELETE') return handleProjectDelete(res, decodeURIComponent(m[1]));
687
716
  }
688
717
  }
718
+ // Project texts (.md контент). Отдельная таблица на сервере; клиент
719
+ // ссылается на записи по id из manifest'а board.texts[relPath] = textId.
720
+ if (req.method === 'POST' && url.pathname === '/api/project-texts') return handleProjectTextCreate(req, res);
721
+ {
722
+ const m = url.pathname.match(/^\/api\/project-texts\/([^/]+)$/);
723
+ if (m) {
724
+ if (req.method === 'GET') return handleProjectTextGet(res, decodeURIComponent(m[1]));
725
+ if (req.method === 'POST') return handleProjectTextUpdate(req, res, decodeURIComponent(m[1]));
726
+ if (req.method === 'DELETE') return handleProjectTextDelete(res, decodeURIComponent(m[1]));
727
+ }
728
+ }
689
729
  if (req.method === 'GET') return serveStatic(res, url);
690
730
  send(res, 404, 'not found');
691
731
  } catch (e) {