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 +25 -0
- package/package.json +1 -1
- package/renderer/cloudProjects.js +164 -42
- package/renderer/settings.js +17 -0
- package/renderer/styles.css +13 -0
- package/server.js +17 -0
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.
|
|
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
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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,
|
|
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:
|
|
770
|
-
// (
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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,
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
950
|
+
if (!coverFhash) coverFhash = await _quickFileHash(coverBlob);
|
|
951
|
+
newCoverHash = { size: coverBlob.size, fhash: coverFhash, url };
|
|
830
952
|
}
|
|
831
953
|
}
|
|
832
954
|
}
|
package/renderer/settings.js
CHANGED
|
@@ -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
|
// Кнопка ▶ Запустить — сверху (без декоративной 🖼-иконки и
|
package/renderer/styles.css
CHANGED
|
@@ -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) {
|