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 +64 -1
- package/package.json +1 -1
- package/renderer/board.js +6 -0
- package/renderer/cloudFs.js +12 -0
- package/renderer/cloudProjects.js +117 -12
- package/renderer/media.js +28 -3
- package/server.js +40 -0
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
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');
|
package/renderer/cloudFs.js
CHANGED
|
@@ -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. Проверяем синхронизирован ли уже
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
if (
|
|
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(
|
|
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:
|
|
295
|
-
//
|
|
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
|
-
|
|
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(
|
|
355
|
-
|
|
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
|
-
}
|
|
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) {
|