kingkont 0.20.4 → 0.20.6

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.4",
3
+ "version": "0.20.6",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -1572,6 +1572,12 @@ async function closeProject() {
1572
1572
  catch (e) { console.warn(`[closeProject] ${label} failed:`, e?.message || e); }
1573
1573
  };
1574
1574
 
1575
+ // КРИТИЧНО первым шагом: слить pending debounced save (см. media.js
1576
+ // scheduleSave). Без этого мутации только что отработавшего chat-тула
1577
+ // теряются — saveTimer ещё ждёт 300мс, а мы уже обнулили state.currentBoard.
1578
+ await safeAwait('flush pending save', async () => {
1579
+ if (typeof window.flushScheduledSave === 'function') await window.flushScheduledSave();
1580
+ });
1575
1581
  safe('persist welcome flag', () => {
1576
1582
  localStorage.setItem('welcomeOnNextStart', '1');
1577
1583
  localStorage.setItem('lastLocation', 'welcome');
@@ -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(),
package/renderer/media.js CHANGED
@@ -345,14 +345,23 @@ const _mediaHydrationObserver = new IntersectionObserver((entries) => {
345
345
  }, { rootMargin: '400px' });
346
346
 
347
347
  let saveTimer = null;
348
+ let _saveInFlight = null; // promise of currently-running save, или null
348
349
  function scheduleSave() {
349
350
  if (!state.currentBoard) return;
350
351
  // Mark dirty чтобы external file-watcher (pollExternalChanges в board.js)
351
352
  // знал что у нас есть pending изменения; reload без подтверждения не делаем.
352
353
  state.currentBoard.dirty = true;
353
354
  clearTimeout(saveTimer);
354
- saveTimer = setTimeout(async () => {
355
- saveTimer = null;
355
+ saveTimer = setTimeout(() => { saveTimer = null; _runSaveNow(); }, 300);
356
+ }
357
+
358
+ // Сериализуем последовательные save'ы (если предыдущий ещё в полёте — ждём
359
+ // его прежде чем запускать новый). Без этого два scheduleSave подряд могли
360
+ // бы конкурентно дёргать FSAH-handle.
361
+ async function _runSaveNow() {
362
+ if (_saveInFlight) { try { await _saveInFlight; } catch {} }
363
+ if (!state.currentBoard?.handle) return;
364
+ _saveInFlight = (async () => {
356
365
  try {
357
366
  const mtime = await saveBoardMetadata(state.currentBoard.handle, state.currentBoard.metadata);
358
367
  // Обновляем lastDiskMtime — иначе pollExternalChanges подумает что это
@@ -362,8 +371,24 @@ function scheduleSave() {
362
371
  }
363
372
  if (state.currentBoard) state.currentBoard.dirty = false;
364
373
  } catch (e) { console.error('save failed', e); }
365
- }, 300);
374
+ })();
375
+ try { await _saveInFlight; } finally { _saveInFlight = null; }
376
+ }
377
+
378
+ // Принудительно слить pending debounced save (и дождаться уже идущего).
379
+ // Нужно перед closeProject — иначе тулы чата, которые только что
380
+ // scheduleSave'нули, теряют изменения когда state.currentBoard обнуляется
381
+ // до того как 300мс-таймер сработает.
382
+ async function flushScheduledSave() {
383
+ if (saveTimer) {
384
+ clearTimeout(saveTimer);
385
+ saveTimer = null;
386
+ await _runSaveNow();
387
+ } else if (_saveInFlight) {
388
+ try { await _saveInFlight; } catch {}
389
+ }
366
390
  }
391
+ window.flushScheduledSave = flushScheduledSave;
367
392
 
368
393
  async function refreshNodeDOM(nodeId) {
369
394
  if (!state.currentBoard) return;
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) {