kingkont 0.20.15 → 0.20.16

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
@@ -1207,6 +1207,29 @@ async function deleteProjectText(id, s) {
1207
1207
  return true;
1208
1208
  }
1209
1209
 
1210
+ // Batch-операции: один HTTP к chatium вместо N. Используются client'ом
1211
+ // при open (download всех .md) и save (upload всех .md).
1212
+ async function batchGetProjectTexts(body, s) {
1213
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
1214
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.projectTexts}~batch-get`;
1215
+ logCall('POST', 'Chatium', url, `n=${body?.ids?.length || 0}`);
1216
+ const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
1217
+ const text = await r.text();
1218
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
1219
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
1220
+ return d;
1221
+ }
1222
+ async function batchSaveProjectTexts(body, s) {
1223
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
1224
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.projectTexts}~batch-save`;
1225
+ logCall('POST', 'Chatium', url, `n=${body?.items?.length || 0}`);
1226
+ const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
1227
+ const text = await r.text();
1228
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
1229
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
1230
+ return d;
1231
+ }
1232
+
1210
1233
  async function listElevenVoices() {
1211
1234
  const key = process.env.ELEVENLABS_API_KEY;
1212
1235
  if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
@@ -1257,6 +1280,8 @@ module.exports = {
1257
1280
  createProjectText,
1258
1281
  getProjectText,
1259
1282
  updateProjectText,
1283
+ batchGetProjectTexts,
1284
+ batchSaveProjectTexts,
1260
1285
  deleteProjectText,
1261
1286
  // Constants (для server.js / тестов)
1262
1287
  KIE_IMAGE_MODELS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.20.15",
3
+ "version": "0.20.16",
4
4
  "description": "KingKont \u00b7 Chatium \u2014 \u043d\u043e\u0434-\u0440\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0441\u0446\u0435\u043d \u0441 AI-\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0435\u0439 (\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438/\u0432\u0438\u0434\u0435\u043e/\u0433\u043e\u043b\u043e\u0441/SFX/\u043c\u0443\u0437\u044b\u043a\u0430/\u0442\u0435\u043a\u0441\u0442)",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -472,10 +472,9 @@
472
472
  }
473
473
  }
474
474
 
475
- // Параллельная синхронная загрузка всех .md-контентов всех бордов.
476
- // Тексты маленькие (1-10KB обычно), качаем сразу — иначе при открытии
477
- // доски юзер видит пустые text-ноды до завершения _backgroundDownload
478
- // (который не запускается в view-only).
475
+ // Загрузка всех .md-контентов всех бордов ОДНИМ batch-запросом.
476
+ // Юзер: «скачивай все текстовые ноды одним api-запросом».
477
+ // Раньше N параллельных fetch'ей (по 8). Теперь — один POST batch-get.
479
478
  async function _downloadAllTexts(projectId, boards) {
480
479
  const tasks = [];
481
480
  for (const board of boards) {
@@ -485,6 +484,31 @@
485
484
  }
486
485
  }
487
486
  if (!tasks.length) return;
487
+ try {
488
+ const r = await fetch('/api/project-texts/batch-get', {
489
+ method: 'POST',
490
+ headers: { 'Content-Type': 'application/json' },
491
+ body: JSON.stringify({ ids: tasks.map(t => t.textId) }),
492
+ });
493
+ if (!r.ok) {
494
+ // Backend без batch-роута — fallback на старый параллельный download.
495
+ await _downloadAllTextsLegacy(projectId, tasks);
496
+ return;
497
+ }
498
+ const { items = [] } = await r.json();
499
+ // batch-get гарантирует порядок: items[i] соответствует tasks[i].
500
+ for (let i = 0; i < tasks.length; i++) {
501
+ const t = tasks[i];
502
+ const it = items[i];
503
+ if (!it || it.error) continue;
504
+ const buf = new TextEncoder().encode(it.content || '');
505
+ try { await writeCloudFile(projectId, joinPath(t.boardDir, t.relPath), buf); } catch {}
506
+ }
507
+ } catch {
508
+ await _downloadAllTextsLegacy(projectId, tasks);
509
+ }
510
+ }
511
+ async function _downloadAllTextsLegacy(projectId, tasks) {
488
512
  const CONCURRENCY = 8;
489
513
  let i = 0;
490
514
  async function worker() {
@@ -576,16 +600,20 @@
576
600
  if (!fr.ok) continue;
577
601
  const buf = new Uint8Array(await fr.arrayBuffer());
578
602
  await writeCloudFile(projectId, joinPath(t.boardDir, t.relPath), buf);
603
+ // content-hash вместо mtime — независимо от write time, тот же
604
+ // алгоритм при save → dedup hit.
605
+ const fhash = await _quickFileHash(new Blob([buf]));
579
606
  fileHashes[`${t.boardKind}/${t.boardName}/${t.relPath}`] =
580
- { url: t.cdnUrl, size: buf.byteLength, mtime: Date.now() };
607
+ { url: t.cdnUrl, size: buf.byteLength, fhash };
581
608
  } else if (t.kind === 'text') {
582
609
  const tr = await fetch('/api/project-texts/' + encodeURIComponent(t.textId));
583
610
  if (!tr.ok) continue;
584
611
  const td = await tr.json();
585
- const buf = new TextEncoder().encode(td.content || '');
612
+ const content = td.content || '';
613
+ const buf = new TextEncoder().encode(content);
586
614
  await writeCloudFile(projectId, joinPath(t.boardDir, t.relPath), buf);
587
615
  textHashes[`${t.boardKind}/${t.boardName}/${t.relPath}`] =
588
- { textId: t.textId, size: buf.byteLength, mtime: Date.now() };
616
+ { textId: t.textId, content };
589
617
  } else if (t.kind === 'cover') {
590
618
  const fr = await fetch('/api/proxy?url=' + encodeURIComponent(t.cdnUrl));
591
619
  if (!fr.ok) continue;
@@ -593,7 +621,8 @@
593
621
  const ct = fr.headers.get('content-type') || 'image/jpeg';
594
622
  const ext = ct.includes('png') ? 'png' : (ct.includes('webp') ? 'webp' : 'jpg');
595
623
  await writeCloudFile(projectId, '.cover.' + ext, buf);
596
- coverHash = { size: buf.byteLength, mtime: Date.now(), url: t.cdnUrl };
624
+ const fhash = await _quickFileHash(new Blob([buf]));
625
+ coverHash = { size: buf.byteLength, fhash, url: t.cdnUrl };
597
626
  }
598
627
  } catch {}
599
628
  }
@@ -616,6 +645,27 @@
616
645
  }
617
646
  function joinPath(a, b) { return a ? (a + '/' + b).replace(/\/+/g, '/') : b; }
618
647
 
648
+ // Быстрый content-hash для dedup'а файлов при save.
649
+ // Раньше дедупили по size+mtime — но mtime файла, записанного в cloudFs/
650
+ // FSAH, не совпадает с тем что мы запомнили (Date.now() в JS != OS-stamped
651
+ // mtime). Поэтому при save после download'а ВСЕ файлы перезаливались.
652
+ // Quick-hash: SHA-1 по первым 64KB + последним 64KB + size (для маленьких
653
+ // файлов <128KB — полное содержимое). Достаточно чтобы отличить изменения
654
+ // (header или footer обычно затрагиваются при edit'е любого media).
655
+ async function _quickFileHash(file) {
656
+ const u8 = new Uint8Array(await file.arrayBuffer());
657
+ let chunk;
658
+ if (u8.byteLength <= 131072) {
659
+ chunk = u8;
660
+ } else {
661
+ chunk = new Uint8Array(131072);
662
+ chunk.set(u8.subarray(0, 65536), 0);
663
+ chunk.set(u8.subarray(u8.byteLength - 65536), 65536);
664
+ }
665
+ const hash = await crypto.subtle.digest('SHA-1', chunk);
666
+ return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
667
+ }
668
+
619
669
  async function writeCloudFile(projectId, relPath, data) {
620
670
  if (window.cloudFs) return window.cloudFs.write(projectId, relPath, data);
621
671
  // Web fallback: пишем в shared in-memory backend, тот же что
@@ -632,11 +682,18 @@
632
682
  if (!isLoggedIn(s)) { alert('Войдите в KingKont'); return; }
633
683
  if (!state.filmHandle) return;
634
684
 
635
- // Если это папочный (не cloud) проект спросим имя и создадим новый
636
- // cloud-проект. Это ответ на «Любой проект том числе тот что создан
637
- // в папке) нужно уметь сохранить на сервер».
638
- const isCloud = !!state.cloudProjectId;
639
- let projectId = state.cloudProjectId;
685
+ // Сначала читаем meta там может быть привязка cloudProjectId от
686
+ // прошлого save'а. Без этого после reload приложения юзер открывал
687
+ // папочный проект и save создавал НОВЫЙ cloud-проект (вместо обновления
688
+ // ранее привязанного). Юзер: «когда я сохраняю проект на сервер — не
689
+ // должно создаваться нового проекта; проект на диске должен получать
690
+ // связку с доской в облаке».
691
+ const meta = (typeof readProjectMeta === 'function')
692
+ ? (await readProjectMeta(state.filmHandle)) || {}
693
+ : {};
694
+ const linkedCloudId = state.cloudProjectId || meta.cloudProjectId || null;
695
+ const isCloud = !!linkedCloudId;
696
+ let projectId = linkedCloudId;
640
697
  let projectName = state.filmHandle.name;
641
698
  if (!isCloud) {
642
699
  // window.prompt() в Electron подавлен — askName из board.js.
@@ -657,16 +714,12 @@
657
714
  const created = await r.json();
658
715
  projectId = created.id;
659
716
  state.cloudProjectId = projectId;
717
+ } else {
718
+ state.cloudProjectId = projectId;
660
719
  }
661
720
 
662
721
  PROGRESS.show('Сбор данных проекта…');
663
722
  try {
664
- // Читаем .kingkont-meta.json — там лежат хеши (size+mtime) → cdnUrl
665
- // для всех ранее загруженных файлов. Используем для dedup'а — не
666
- // перезаливаем неизменённые файлы.
667
- const meta = (typeof readProjectMeta === 'function')
668
- ? (await readProjectMeta(state.filmHandle)) || {}
669
- : {};
670
723
  const oldHashes = meta.fileHashes || {};
671
724
  const oldTextHashes = meta.textHashes || {};
672
725
  const newHashes = {};
@@ -681,6 +734,53 @@
681
734
  let uploaded = 0;
682
735
  let reused = 0;
683
736
 
737
+ // Pre-pass: один batch-запрос на ВСЕ изменённые text-ноды.
738
+ // Юзер: «сохраняй все текстовые ноды одним api-запросом».
739
+ // Раньше — N fetch'ей (один per .md). Cached-hits не отправляем.
740
+ const batchTextItems = [];
741
+ for (const board of allBoards) {
742
+ for (const f of board.mediaFiles) {
743
+ if (!f.fileHandle) continue;
744
+ if (!/\.md$/i.test(f.relPath)) continue;
745
+ const metaKey = `${board.kind}/${board.name}/${f.relPath}`;
746
+ try {
747
+ const file = await f.fileHandle.getFile();
748
+ const content = await file.text();
749
+ const cached = oldTextHashes[metaKey];
750
+ if (cached && cached.content === content && cached.textId) continue;
751
+ batchTextItems.push({
752
+ metaKey, relPath: f.relPath, content,
753
+ oldTextId: cached?.textId || null,
754
+ });
755
+ } catch {}
756
+ }
757
+ }
758
+ const textResultsByMetaKey = new Map();
759
+ if (batchTextItems.length) {
760
+ try {
761
+ const r = await fetch('/api/project-texts/batch-save', {
762
+ method: 'POST',
763
+ headers: { 'Content-Type': 'application/json' },
764
+ body: JSON.stringify({
765
+ items: batchTextItems.map(it => ({
766
+ id: it.oldTextId || undefined,
767
+ projectId, relPath: it.relPath, content: it.content,
768
+ })),
769
+ }),
770
+ });
771
+ if (r.ok) {
772
+ const { items: results = [] } = await r.json();
773
+ for (let i = 0; i < batchTextItems.length; i++) {
774
+ textResultsByMetaKey.set(batchTextItems[i].metaKey, results[i] || { error: 'no_result' });
775
+ }
776
+ } else {
777
+ console.warn(`[cloudProjects] batch-save ${r.status} → fallback на per-item`);
778
+ }
779
+ } catch (e) {
780
+ console.warn(`[cloudProjects] batch-save error:`, e?.message || e);
781
+ }
782
+ }
783
+
684
784
  for (const board of allBoards) {
685
785
  const sceneFile = await board.handle.getFileHandle('scene.json').catch(() => null);
686
786
  let scene = {};
@@ -707,8 +807,9 @@
707
807
  // автоматически, без обновления клиента.
708
808
  if (isMd) {
709
809
  const cachedText = oldTextHashes[metaKey];
710
- // Dedup: size+mtime совпадают → переиспользуем textId, нет API-вызова.
711
- if (cachedText && cachedText.size === file.size && cachedText.mtime === file.lastModified && cachedText.textId) {
810
+ const content = await file.text();
811
+ // Dedup hit (content не менялся) переиспользуем textId.
812
+ if (cachedText && cachedText.content === content && cachedText.textId) {
712
813
  boardTexts[f.relPath] = cachedText.textId;
713
814
  newTextHashes[metaKey] = cachedText;
714
815
  reused++;
@@ -717,9 +818,19 @@
717
818
  `[${board.kind}/${board.name}] ${f.relPath} (cached, ${uploaded}/${total})`);
718
819
  continue;
719
820
  }
821
+ // Batch-save уже отработал — используем готовый результат.
822
+ const bres = textResultsByMetaKey.get(metaKey);
823
+ if (bres && bres.id) {
824
+ boardTexts[f.relPath] = bres.id;
825
+ newTextHashes[metaKey] = { textId: bres.id, content };
826
+ uploaded++;
827
+ PROGRESS.update(uploaded, total,
828
+ `[${board.kind}/${board.name}] ${f.relPath} (batch, ${uploaded}/${total})`);
829
+ continue;
830
+ }
831
+ // Batch не сработал для этого item'а → per-item fallback (старый путь).
720
832
  PROGRESS.update(uploaded, total,
721
833
  `[${board.kind}/${board.name}] ${f.relPath} (text, ${uploaded + 1}/${total})`);
722
- const content = await file.text();
723
834
  let textId = cachedText?.textId || null;
724
835
  let dbPathFailed = false;
725
836
  if (textId) {
@@ -757,7 +868,7 @@
757
868
  }
758
869
  if (textId) {
759
870
  boardTexts[f.relPath] = textId;
760
- newTextHashes[metaKey] = { textId, size: file.size, mtime: file.lastModified };
871
+ newTextHashes[metaKey] = { textId, content };
761
872
  uploaded++;
762
873
  continue;
763
874
  }
@@ -766,18 +877,22 @@
766
877
 
767
878
  // ---- Бинарь → CDN через /api/upload. ----
768
879
  const cached = oldHashes[metaKey];
769
- // Dedup: size + mtime совпадают переиспользуем CDN-URL.
770
- // (Это identifier «файл не менялся с момента последней синхронизации»;
771
- // неважно загружали мы его сами или скачали при openCloudProject.)
772
- if (cached && cached.size === file.size && cached.mtime === file.lastModified) {
773
- boardFiles[f.relPath] = cached.url;
774
- allFilesIndex[metaKey] = cached.url;
775
- newHashes[metaKey] = cached;
776
- reused++;
777
- uploaded++;
778
- PROGRESS.update(uploaded, total,
779
- `[${board.kind}/${board.name}] ${f.relPath} (cached, ${uploaded}/${total})`);
780
- continue;
880
+ // Dedup: сначала быстрая проверка по size, потом content-hash.
881
+ // Mtime НЕ используем — Date.now() в JS не совпадает с OS-stamped
882
+ // mtime, после download/write валидные файлы выглядели как «новые».
883
+ let fhash = null;
884
+ if (cached && cached.size === file.size) {
885
+ fhash = await _quickFileHash(file);
886
+ if (cached.fhash === fhash) {
887
+ boardFiles[f.relPath] = cached.url;
888
+ allFilesIndex[metaKey] = cached.url;
889
+ newHashes[metaKey] = cached;
890
+ reused++;
891
+ uploaded++;
892
+ PROGRESS.update(uploaded, total,
893
+ `[${board.kind}/${board.name}] ${f.relPath} (cached, ${uploaded}/${total})`);
894
+ continue;
895
+ }
781
896
  }
782
897
  PROGRESS.update(uploaded, total,
783
898
  `[${board.kind}/${board.name}] ${f.relPath} (${uploaded + 1}/${total})`);
@@ -791,9 +906,10 @@
791
906
  });
792
907
  if (!r.ok) throw new Error(`upload ${f.relPath} failed: ${r.status}`);
793
908
  const j = await r.json();
909
+ if (!fhash) fhash = await _quickFileHash(file);
794
910
  boardFiles[f.relPath] = j.url;
795
911
  allFilesIndex[metaKey] = j.url;
796
- newHashes[metaKey] = { url: j.url, size: file.size, mtime: file.lastModified };
912
+ newHashes[metaKey] = { url: j.url, size: file.size, fhash };
797
913
  uploaded++;
798
914
  }
799
915
  const boardEntry = { kind: board.kind, name: board.name, scene, files: boardFiles };
@@ -809,11 +925,16 @@
809
925
  const coverBlob = await readProjectCover(state.filmHandle);
810
926
  if (coverBlob) {
811
927
  const ch = meta.coverHash;
812
- if (ch && ch.size === coverBlob.size && ch.mtime === coverBlob.lastModified && ch.url) {
813
- // Cover не менялся переиспользуем CDN-URL.
814
- coverUrl = ch.url;
815
- PROGRESS.update(uploaded, total, 'Обложка не менялась (cached)');
816
- } else {
928
+ let coverFhash = null;
929
+ if (ch && ch.size === coverBlob.size && ch.url) {
930
+ coverFhash = await _quickFileHash(coverBlob);
931
+ if (ch.fhash === coverFhash) {
932
+ coverUrl = ch.url;
933
+ PROGRESS.update(uploaded, total, 'Обложка не менялась (cached)');
934
+ newCoverHash = ch;
935
+ }
936
+ }
937
+ if (!newCoverHash || newCoverHash !== ch) {
817
938
  PROGRESS.update(uploaded, total, 'Загрузка обложки…');
818
939
  const r = await fetch('/api/upload', {
819
940
  method: 'POST',
@@ -826,7 +947,8 @@
826
947
  if (r.ok) {
827
948
  const { url } = await r.json();
828
949
  coverUrl = url;
829
- newCoverHash = { size: coverBlob.size, mtime: coverBlob.lastModified, url };
950
+ if (!coverFhash) coverFhash = await _quickFileHash(coverBlob);
951
+ newCoverHash = { size: coverBlob.size, fhash: coverFhash, url };
830
952
  }
831
953
  }
832
954
  }
@@ -552,6 +552,23 @@ async function renderNodeBody(node, body) {
552
552
  playRow.appendChild(playBtn);
553
553
  body.appendChild(playRow);
554
554
  }
555
+ // Описание (опц.) — для image/video нод. Юзер: «под каждой нодой
556
+ // картинки или видео должно быть возможно добавить описание».
557
+ // Хранится в node.description (scene.json). Показываем всегда (даже
558
+ // пустым) — клик в textarea = начать писать.
559
+ if (node.type === 'image' || node.type === 'video') {
560
+ const desc = document.createElement('textarea');
561
+ desc.className = 'node-description';
562
+ desc.placeholder = 'Описание (опц.)';
563
+ desc.value = node.description || '';
564
+ desc.rows = 1;
565
+ desc.addEventListener('mousedown', e => e.stopPropagation());
566
+ desc.addEventListener('input', () => {
567
+ node.description = desc.value;
568
+ scheduleSave();
569
+ });
570
+ body.appendChild(desc);
571
+ }
555
572
  } else if (node.type === 'image' && !node.file && (node.generated?.rawPrompt || node.generated?.prompt)) {
556
573
  // Image-нода с промптом, но без файла (ещё не генерилась).
557
574
  // Кнопка ▶ Запустить — сверху (без декоративной 🖼-иконки и
@@ -110,6 +110,19 @@
110
110
  width: 36px; height: 36px; flex-shrink: 0; object-fit: contain;
111
111
  background: #1a1a1a; border-radius: 8px; padding: 4px;
112
112
  }
113
+ /* Описание под image/video нодой. Auto-grow по содержимому. */
114
+ .node .node-description {
115
+ width: 100%; min-height: 28px; max-height: 120px;
116
+ margin-top: 6px; padding: 4px 6px;
117
+ background: #1e1e1e; color: #ccc;
118
+ border: 1px solid #333; border-radius: 3px;
119
+ font-family: inherit; font-size: 12px; line-height: 1.4;
120
+ resize: vertical; box-sizing: border-box;
121
+ outline: none;
122
+ }
123
+ .node .node-description:focus { border-color: #4a6a9a; }
124
+ .node .node-description::placeholder { color: #555; }
125
+
113
126
  /* Link-badge: значок 🔗 на ноде когда у неё есть linkedBoard. Лежит в
114
127
  правом-верхнем углу, кликабельный (= dblclick: открыть target-сцену).
115
128
  По умолчанию скрыт (display:none); refreshNodeLinkBadge показывает. */
package/server.js CHANGED
@@ -518,6 +518,20 @@ async function handleProjectTextDelete(res, id) {
518
518
  } catch (e) { sendError(res, e, 502); }
519
519
  }
520
520
 
521
+ // Batch endpoints — проксируют один запрос в chatium /project_texts~batch-*.
522
+ async function handleProjectTextBatchGet(req, res) {
523
+ try {
524
+ const body = await readJson(req); // { ids: string[] }
525
+ send(res, 200, await providers.batchGetProjectTexts(body, getSettings()));
526
+ } catch (e) { sendError(res, e, 502); }
527
+ }
528
+ async function handleProjectTextBatchSave(req, res) {
529
+ try {
530
+ const body = await readJson(req); // { items: [...] }
531
+ send(res, 200, await providers.batchSaveProjectTexts(body, getSettings()));
532
+ } catch (e) { sendError(res, e, 502); }
533
+ }
534
+
521
535
  // =============================================================================
522
536
  // Chat sessions: server-side LLM-loop с tool-pump pattern.
523
537
  // Клиент: POST /api/chat/send {key, text, system} → starts loop
@@ -753,6 +767,9 @@ async function _requestHandler(req, res) {
753
767
  // Project texts (.md контент). Отдельная таблица на сервере; клиент
754
768
  // ссылается на записи по id из manifest'а board.texts[relPath] = textId.
755
769
  if (req.method === 'POST' && url.pathname === '/api/project-texts') return handleProjectTextCreate(req, res);
770
+ // Batch-операции (отдельные роуты с одним fetch для N items).
771
+ if (req.method === 'POST' && url.pathname === '/api/project-texts/batch-get') return handleProjectTextBatchGet(req, res);
772
+ if (req.method === 'POST' && url.pathname === '/api/project-texts/batch-save') return handleProjectTextBatchSave(req, res);
756
773
  {
757
774
  const m = url.pathname.match(/^\/api\/project-texts\/([^/]+)$/);
758
775
  if (m) {