kingkont 0.20.7 → 0.20.8
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/package.json +1 -1
- package/renderer/board.js +215 -20
- package/renderer/cloudProjects.js +13 -0
- package/renderer/generate.js +36 -5
- package/renderer/settings.js +1 -1
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -17,7 +17,19 @@ const emptyState = $('emptyState');
|
|
|
17
17
|
|
|
18
18
|
// =================== Init ===================
|
|
19
19
|
window.addEventListener('DOMContentLoaded', async () => {
|
|
20
|
-
|
|
20
|
+
// FSAH (showDirectoryPicker) — Chrome-only API. Без него работают только
|
|
21
|
+
// облачные проекты. В web-mode (Safari/Firefox) → продолжаем init без
|
|
22
|
+
// папочного функционала; кнопки «Открыть папку» скрыты через body.web-mode CSS.
|
|
23
|
+
// В Electron / desktop Chrome — без FSAH вообще ничего не работало бы,
|
|
24
|
+
// показываем заглушку.
|
|
25
|
+
if (!('showDirectoryPicker' in window)) {
|
|
26
|
+
if (!window.__KINGKONT_WEB__) { showUnsupported(); return; }
|
|
27
|
+
// Прячем папочные UI-элементы — без FSAH они нерабочие.
|
|
28
|
+
for (const id of ['pickRoot', 'welcomeOpen']) {
|
|
29
|
+
const el = document.getElementById(id);
|
|
30
|
+
if (el) el.style.display = 'none';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
21
33
|
// По умолчанию проект не открыт — секции персонажей/локаций/сцен скрыты.
|
|
22
34
|
document.body.classList.add('no-project');
|
|
23
35
|
loadVoices().catch(() => {}); // preload голосов в фоне
|
|
@@ -711,23 +723,26 @@ async function _renderWelcomeRecentsInner() {
|
|
|
711
723
|
|
|
712
724
|
// Первой картой — «Открыть проект». Кликается → дёргает скрытый
|
|
713
725
|
// #pickRoot button (тот же, что использует app menu).
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
726
|
+
// Скрываем в браузерах без FSAH (Safari/Firefox) — там кнопка не сработает.
|
|
727
|
+
if ('showDirectoryPicker' in window) {
|
|
728
|
+
const openCard = document.createElement('div');
|
|
729
|
+
openCard.className = 'welcome-card open-card';
|
|
730
|
+
const openThumb = document.createElement('div');
|
|
731
|
+
openThumb.className = 'welcome-card-thumb';
|
|
732
|
+
openThumb.textContent = '+';
|
|
733
|
+
const openMeta = document.createElement('div');
|
|
734
|
+
openMeta.className = 'welcome-card-meta';
|
|
735
|
+
const openName = document.createElement('div');
|
|
736
|
+
openName.className = 'welcome-card-name';
|
|
737
|
+
openName.textContent = 'Открыть проект';
|
|
738
|
+
const openSub = document.createElement('div');
|
|
739
|
+
openSub.className = 'welcome-card-ts';
|
|
740
|
+
openSub.textContent = 'выбрать папку…';
|
|
741
|
+
openMeta.append(openName, openSub);
|
|
742
|
+
openCard.append(openThumb, openMeta);
|
|
743
|
+
openCard.addEventListener('click', () => $('pickRoot').click());
|
|
744
|
+
grid.appendChild(openCard);
|
|
745
|
+
}
|
|
731
746
|
|
|
732
747
|
// ☁ Облачные проекты — видна только если залогинен в Chatium.
|
|
733
748
|
// Создаёт серверную запись и открывает её как новый проект.
|
|
@@ -815,9 +830,12 @@ async function _renderWelcomeRecentsInner() {
|
|
|
815
830
|
// Каждый элемент: { type: 'cloud'|'recent', sortTs, item }.
|
|
816
831
|
// Для cloud берём максимум из (updatedAt | lastOpenedAt | createdAt) —
|
|
817
832
|
// юзер ожидает что недавно открытый проект (даже без сохранения) поднимется.
|
|
833
|
+
// Расшаренные мне НЕ кладём в основной grid — отдельная секция «Расшарено мне» ниже.
|
|
818
834
|
const merged = [];
|
|
835
|
+
const sharedItems = [];
|
|
819
836
|
for (const c of cloudItems) {
|
|
820
837
|
const ts = Math.max(c.lastOpenedAt || 0, c.updatedAt || 0, c.createdAt || 0);
|
|
838
|
+
if (c.shared && !c.mine) { sharedItems.push({ ...c, sortTs: ts }); continue; }
|
|
821
839
|
merged.push({ type: 'cloud', sortTs: ts, item: c });
|
|
822
840
|
}
|
|
823
841
|
// Дедуп: cloud-проекты не должны показываться ещё и как «недавние папки».
|
|
@@ -840,6 +858,28 @@ async function _renderWelcomeRecentsInner() {
|
|
|
840
858
|
if (m.type === 'cloud') grid.appendChild(makeCloudWelcomeCard(m.item));
|
|
841
859
|
else grid.appendChild(makeRecentWelcomeCard(m.item));
|
|
842
860
|
}
|
|
861
|
+
|
|
862
|
+
// ---- Расшарили мне — отдельной секцией под основным grid'ом. ----
|
|
863
|
+
// Лежит как отдельный block #welcomeSharedRecent. Если есть items — показываем,
|
|
864
|
+
// иначе скрываем.
|
|
865
|
+
let sharedWrap = document.getElementById('welcomeSharedRecent');
|
|
866
|
+
if (!sharedWrap) {
|
|
867
|
+
sharedWrap = document.createElement('div');
|
|
868
|
+
sharedWrap.id = 'welcomeSharedRecent';
|
|
869
|
+
sharedWrap.className = 'welcome-recent';
|
|
870
|
+
sharedWrap.style.marginTop = '32px';
|
|
871
|
+
sharedWrap.innerHTML = '<div class="welcome-recent-title">Расшарено мне</div><div class="welcome-recent-grid" id="welcomeSharedGrid"></div>';
|
|
872
|
+
wrap.parentNode?.insertBefore(sharedWrap, wrap.nextSibling);
|
|
873
|
+
}
|
|
874
|
+
const sharedGrid = sharedWrap.querySelector('#welcomeSharedGrid');
|
|
875
|
+
sharedGrid.innerHTML = '';
|
|
876
|
+
if (!sharedItems.length) {
|
|
877
|
+
sharedWrap.style.display = 'none';
|
|
878
|
+
} else {
|
|
879
|
+
sharedWrap.style.display = '';
|
|
880
|
+
sharedItems.sort((a, b) => b.sortTs - a.sortTs);
|
|
881
|
+
for (const it of sharedItems) sharedGrid.appendChild(makeCloudWelcomeCard(it));
|
|
882
|
+
}
|
|
843
883
|
}
|
|
844
884
|
|
|
845
885
|
// Бейдж «N в фоне» в верхнем-левом углу обложки welcome-карточки.
|
|
@@ -1159,8 +1199,16 @@ function makeCloudWelcomeCard(p) {
|
|
|
1159
1199
|
// ☁-бейдж в нижнем правом углу обложки.
|
|
1160
1200
|
const cloudBadge = document.createElement('div');
|
|
1161
1201
|
cloudBadge.className = 'welcome-card-cloud-badge';
|
|
1162
|
-
|
|
1163
|
-
|
|
1202
|
+
if (p.shared) {
|
|
1203
|
+
cloudBadge.textContent = '🤝';
|
|
1204
|
+
cloudBadge.title = 'Расшарили мне (' + (p.permission === 'edit' ? 'редактирование' : 'просмотр') + ')';
|
|
1205
|
+
} else if (p.isPublic) {
|
|
1206
|
+
cloudBadge.textContent = '🌐';
|
|
1207
|
+
cloudBadge.title = 'Публичный проект';
|
|
1208
|
+
} else {
|
|
1209
|
+
cloudBadge.textContent = '☁';
|
|
1210
|
+
cloudBadge.title = 'Облачный проект';
|
|
1211
|
+
}
|
|
1164
1212
|
thumb.appendChild(cloudBadge);
|
|
1165
1213
|
// Бейдж «N в фоне» — server-side jobsHub source-of-truth.
|
|
1166
1214
|
const bgList = _bgServerCache['cloud:' + p.id];
|
|
@@ -1188,6 +1236,123 @@ function makeCloudWelcomeCard(p) {
|
|
|
1188
1236
|
return card;
|
|
1189
1237
|
}
|
|
1190
1238
|
|
|
1239
|
+
// Floating-overlay: «Создать копию» внизу экрана, когда юзер открыл
|
|
1240
|
+
// чужой проект (расшаренный или публичный). После клона перебрасываем
|
|
1241
|
+
// на новую копию.
|
|
1242
|
+
function renderTemplateOverlay() {
|
|
1243
|
+
const existing = document.getElementById('templateOverlayBar');
|
|
1244
|
+
const isReadOnly = state.cloudProjectId && state.cloudCanModify === false;
|
|
1245
|
+
if (!isReadOnly) { if (existing) existing.remove(); return; }
|
|
1246
|
+
if (existing) return;
|
|
1247
|
+
const bar = document.createElement('div');
|
|
1248
|
+
bar.id = 'templateOverlayBar';
|
|
1249
|
+
bar.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#222;color:#eee;padding:12px 20px;border-radius:8px;display:flex;gap:14px;align-items:center;font-family:-apple-system,sans-serif;font-size:14px;z-index:9999;box-shadow:0 6px 24px rgba(0,0,0,0.4);';
|
|
1250
|
+
const lbl = document.createElement('div');
|
|
1251
|
+
lbl.innerHTML = '👁 Только просмотр' + (state.cloudPermission === 'view' ? ' (расшарили)' : (state.cloudMine === false ? ' (публичный)' : ''));
|
|
1252
|
+
lbl.style.opacity = '0.8';
|
|
1253
|
+
const btn = document.createElement('button');
|
|
1254
|
+
btn.textContent = '📋 Создать копию себе';
|
|
1255
|
+
btn.style.cssText = 'padding:8px 18px;background:#4a8ad6;color:#fff;border:none;border-radius:5px;cursor:pointer;font-weight:500;';
|
|
1256
|
+
btn.addEventListener('click', async () => {
|
|
1257
|
+
btn.disabled = true; btn.textContent = '… клонирую';
|
|
1258
|
+
try {
|
|
1259
|
+
const r = await fetch('/api/proj/clone?id=' + encodeURIComponent(state.cloudProjectId), {
|
|
1260
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1261
|
+
body: JSON.stringify({}),
|
|
1262
|
+
});
|
|
1263
|
+
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.error || 'HTTP ' + r.status); }
|
|
1264
|
+
const created = await r.json();
|
|
1265
|
+
// Чистим cache и открываем новый клон.
|
|
1266
|
+
try { localStorage.removeItem('cloudProjectsCache'); } catch {}
|
|
1267
|
+
if (window.cloudProjects?.open) {
|
|
1268
|
+
await closeProject();
|
|
1269
|
+
await window.cloudProjects.open(created.id, created.name, { forceRefresh: true });
|
|
1270
|
+
}
|
|
1271
|
+
} catch (e) { btn.disabled = false; btn.textContent = '📋 Создать копию себе'; alert('Не удалось: ' + (e?.message || e)); }
|
|
1272
|
+
});
|
|
1273
|
+
bar.append(lbl, btn);
|
|
1274
|
+
document.body.appendChild(bar);
|
|
1275
|
+
}
|
|
1276
|
+
window.renderTemplateOverlay = renderTemplateOverlay;
|
|
1277
|
+
|
|
1278
|
+
// Модалка расшаривания проекта (web-only).
|
|
1279
|
+
async function openShareModal(p) {
|
|
1280
|
+
// Сначала загружаем текущие shares (owner-only endpoint).
|
|
1281
|
+
let shares = [];
|
|
1282
|
+
try {
|
|
1283
|
+
const r = await fetch('/api/proj/shares?id=' + encodeURIComponent(p.id));
|
|
1284
|
+
if (r.ok) shares = await r.json();
|
|
1285
|
+
} catch {}
|
|
1286
|
+
const overlay = document.createElement('div');
|
|
1287
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:99998;display:flex;align-items:center;justify-content:center;';
|
|
1288
|
+
const box = document.createElement('div');
|
|
1289
|
+
box.style.cssText = 'background:#222;color:#eee;padding:24px;border-radius:8px;min-width:380px;max-width:520px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';
|
|
1290
|
+
function render() {
|
|
1291
|
+
box.innerHTML = `
|
|
1292
|
+
<h2 style="margin:0 0 12px;font-size:18px;">🤝 Расшарить «${escapeHtml(p.name||'')}»</h2>
|
|
1293
|
+
<div style="margin-bottom:14px;">
|
|
1294
|
+
<input id="shareEmail" type="email" placeholder="email@example.com" style="width:100%;padding:8px;background:#1a1a1a;color:#eee;border:1px solid #333;border-radius:4px;font-size:14px;">
|
|
1295
|
+
<div style="display:flex;gap:8px;margin-top:8px;">
|
|
1296
|
+
<select id="sharePerm" style="flex:1;padding:8px;background:#1a1a1a;color:#eee;border:1px solid #333;border-radius:4px;">
|
|
1297
|
+
<option value="view">Только просмотр</option>
|
|
1298
|
+
<option value="edit">Редактирование</option>
|
|
1299
|
+
</select>
|
|
1300
|
+
<button id="shareAdd" style="padding:8px 16px;background:#4a8ad6;color:#fff;border:none;border-radius:4px;cursor:pointer;">Расшарить</button>
|
|
1301
|
+
</div>
|
|
1302
|
+
</div>
|
|
1303
|
+
<div id="shareList">
|
|
1304
|
+
${shares.length ? '' : '<div style="opacity:0.5;font-size:13px;text-align:center;padding:20px;">Никому не расшарено</div>'}
|
|
1305
|
+
${shares.map(s => `
|
|
1306
|
+
<div style="display:flex;align-items:center;padding:8px;background:#1a1a1a;border-radius:4px;margin-bottom:6px;">
|
|
1307
|
+
<div style="flex:1;">
|
|
1308
|
+
<div style="font-size:14px;">${escapeHtml(s.email)}</div>
|
|
1309
|
+
<div style="font-size:11px;opacity:0.6;">${s.permission === 'edit' ? '✎ редактирование' : '👁 просмотр'}</div>
|
|
1310
|
+
</div>
|
|
1311
|
+
<button data-unshare="${escapeHtml(s.email)}" style="padding:4px 10px;background:#553;color:#eee;border:none;border-radius:3px;cursor:pointer;font-size:12px;">убрать</button>
|
|
1312
|
+
</div>
|
|
1313
|
+
`).join('')}
|
|
1314
|
+
</div>
|
|
1315
|
+
<div style="text-align:right;margin-top:16px;">
|
|
1316
|
+
<button id="shareClose" style="padding:8px 16px;background:#444;color:#fff;border:none;border-radius:4px;cursor:pointer;">Закрыть</button>
|
|
1317
|
+
</div>
|
|
1318
|
+
`;
|
|
1319
|
+
box.querySelector('#shareAdd').addEventListener('click', async () => {
|
|
1320
|
+
const email = box.querySelector('#shareEmail').value.trim();
|
|
1321
|
+
const perm = box.querySelector('#sharePerm').value;
|
|
1322
|
+
if (!email || !email.includes('@')) { alert('Некорректный email'); return; }
|
|
1323
|
+
try {
|
|
1324
|
+
const r = await fetch('/api/proj/share?id=' + encodeURIComponent(p.id), {
|
|
1325
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1326
|
+
body: JSON.stringify({ email, permission: perm }),
|
|
1327
|
+
});
|
|
1328
|
+
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.error || 'HTTP ' + r.status); }
|
|
1329
|
+
const r2 = await fetch('/api/proj/shares?id=' + encodeURIComponent(p.id));
|
|
1330
|
+
if (r2.ok) shares = await r2.json();
|
|
1331
|
+
render();
|
|
1332
|
+
} catch (e) { alert('Не удалось: ' + (e?.message || e)); }
|
|
1333
|
+
});
|
|
1334
|
+
box.querySelectorAll('[data-unshare]').forEach(b => {
|
|
1335
|
+
b.addEventListener('click', async () => {
|
|
1336
|
+
const email = b.getAttribute('data-unshare');
|
|
1337
|
+
try {
|
|
1338
|
+
await fetch('/api/proj/unshare?id=' + encodeURIComponent(p.id), {
|
|
1339
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1340
|
+
body: JSON.stringify({ email }),
|
|
1341
|
+
});
|
|
1342
|
+
shares = shares.filter(s => s.email !== email);
|
|
1343
|
+
render();
|
|
1344
|
+
} catch (e) { alert('Не удалось: ' + (e?.message || e)); }
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
box.querySelector('#shareClose').addEventListener('click', () => overlay.remove());
|
|
1348
|
+
}
|
|
1349
|
+
render();
|
|
1350
|
+
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
|
1351
|
+
overlay.appendChild(box);
|
|
1352
|
+
document.body.appendChild(overlay);
|
|
1353
|
+
setTimeout(() => box.querySelector('#shareEmail')?.focus(), 50);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1191
1356
|
// ПКМ-меню для облачной welcome-карточки.
|
|
1192
1357
|
function showCloudCardContextMenu(p, clientX, clientY) {
|
|
1193
1358
|
const menu = $('nodeMenu');
|
|
@@ -1235,6 +1400,34 @@ function showCloudCardContextMenu(p, clientX, clientY) {
|
|
|
1235
1400
|
add('↻ Обновить из облака', () => {
|
|
1236
1401
|
if (window.cloudProjects?.open) window.cloudProjects.open(p.id, p.name, { forceRefresh: true });
|
|
1237
1402
|
});
|
|
1403
|
+
// Public/share — только в web-mode (chatium endpoints proj_*).
|
|
1404
|
+
if (window.__KINGKONT_WEB__ && p.mine !== false) {
|
|
1405
|
+
const isPub = !!p.isPublic;
|
|
1406
|
+
add(isPub ? '✓ Публичный (выключить)' : '🌐 Сделать публичным', async () => {
|
|
1407
|
+
try {
|
|
1408
|
+
const r = await fetch('/api/proj/setPublic?id=' + encodeURIComponent(p.id), {
|
|
1409
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1410
|
+
body: JSON.stringify({ isPublic: !isPub }),
|
|
1411
|
+
});
|
|
1412
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1413
|
+
try {
|
|
1414
|
+
const cached = JSON.parse(localStorage.getItem('cloudProjectsCache') || '[]');
|
|
1415
|
+
const i = cached.findIndex(c => c.id === p.id);
|
|
1416
|
+
if (i >= 0) cached[i].isPublic = !isPub;
|
|
1417
|
+
localStorage.setItem('cloudProjectsCache', JSON.stringify(cached));
|
|
1418
|
+
} catch {}
|
|
1419
|
+
await renderWelcomeRecents();
|
|
1420
|
+
if (!isPub) {
|
|
1421
|
+
// Показываем юзеру публичную ссылку
|
|
1422
|
+
const url = location.origin + '/app/spaces/client/index.html#template=' + p.id;
|
|
1423
|
+
if (confirm('Проект публичный. Скопировать ссылку?\n' + url)) {
|
|
1424
|
+
try { await navigator.clipboard.writeText(url); } catch {}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
} catch (e) { alert('Не удалось: ' + (e?.message || e)); }
|
|
1428
|
+
});
|
|
1429
|
+
add('🤝 Расшарить…', () => openShareModal(p));
|
|
1430
|
+
}
|
|
1238
1431
|
add('💾 Сохранить как шаблон…', async () => {
|
|
1239
1432
|
// Берём cloud-проект с сервера (manifest+files) и постим в /api/templates
|
|
1240
1433
|
// в template-формате (board.scene → board.manifest). Без открытия проекта.
|
|
@@ -1625,6 +1818,8 @@ async function closeProject() {
|
|
|
1625
1818
|
}
|
|
1626
1819
|
// Reset tab title.
|
|
1627
1820
|
document.title = 'KingKont';
|
|
1821
|
+
// Убираем floating «Создать копию» bar если был.
|
|
1822
|
+
document.getElementById('templateOverlayBar')?.remove();
|
|
1628
1823
|
state.charactersInfo = [];
|
|
1629
1824
|
state.locationsInfo = [];
|
|
1630
1825
|
state.selectedNodeIds.clear();
|
|
@@ -280,6 +280,15 @@
|
|
|
280
280
|
await openFilm(handle);
|
|
281
281
|
setCloudButtonsVisibility();
|
|
282
282
|
if (window.__KINGKONT_WEB__) location.hash = '#project=' + projectId;
|
|
283
|
+
// Background — узнаём permission (cache не хранил его). Если
|
|
284
|
+
// оказалось что не наш — рисуем «Создать копию» поверх.
|
|
285
|
+
fetch('/api/projects/' + encodeURIComponent(projectId)).then(r => r.ok ? r.json() : null).then(p => {
|
|
286
|
+
if (!p) return;
|
|
287
|
+
state.cloudPermission = p.permission || (p.mine ? 'owner' : null);
|
|
288
|
+
state.cloudMine = !!p.mine;
|
|
289
|
+
state.cloudCanModify = !!p.canModify;
|
|
290
|
+
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
291
|
+
}).catch(() => {});
|
|
283
292
|
return;
|
|
284
293
|
}
|
|
285
294
|
}
|
|
@@ -396,9 +405,13 @@
|
|
|
396
405
|
// 5) Открываем.
|
|
397
406
|
state.cloudProjectId = projectId;
|
|
398
407
|
state.cloudDirty = false;
|
|
408
|
+
state.cloudPermission = proj.permission || (proj.mine ? 'owner' : null);
|
|
409
|
+
state.cloudMine = !!proj.mine;
|
|
410
|
+
state.cloudCanModify = !!proj.canModify;
|
|
399
411
|
touchCloudOpened(projectId);
|
|
400
412
|
await openFilm(handle);
|
|
401
413
|
setCloudButtonsVisibility();
|
|
414
|
+
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
402
415
|
if (window.__KINGKONT_WEB__) location.hash = '#project=' + projectId;
|
|
403
416
|
PROGRESS.hide();
|
|
404
417
|
} catch (e) {
|
package/renderer/generate.js
CHANGED
|
@@ -537,10 +537,17 @@ function _mimeFromFilename(name) {
|
|
|
537
537
|
})[ext] || 'image/jpeg';
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
// Кэш URL'ов картинок-референсов: ключ = filename+size+mtime → CDN url.
|
|
541
|
+
// Чтобы не перезаливать одну и ту же картинку при каждом text-gen.
|
|
542
|
+
const _imageRefUploadCache = new Map();
|
|
543
|
+
|
|
540
544
|
async function _imageRefToDataUrl(ref) {
|
|
541
545
|
// Возвращает {url, size, mime} или {error}. Раньше тихо возвращал null
|
|
542
546
|
// на любую ошибку → дебаг был невозможен (юзер видел «картинка не
|
|
543
547
|
// подаётся», но в логах ничего).
|
|
548
|
+
// Большие картинки (>400KB) грузим в CDN через /api/upload вместо data URL —
|
|
549
|
+
// иначе base64-payload раздувает контекст модели и Claude возвращает
|
|
550
|
+
// «prompt too long: 215k tokens > 200k».
|
|
544
551
|
try {
|
|
545
552
|
if (!ref || !ref.file) return { error: 'no file in ref' };
|
|
546
553
|
if (!ref.boardHandle || typeof ref.boardHandle.getDirectoryHandle !== 'function') {
|
|
@@ -555,9 +562,34 @@ async function _imageRefToDataUrl(ref) {
|
|
|
555
562
|
const mime = file.type && file.type !== 'application/octet-stream'
|
|
556
563
|
? file.type
|
|
557
564
|
: _mimeFromFilename(ref.file);
|
|
565
|
+
|
|
566
|
+
// Большая картинка → upload to CDN, возвращаем https-url. Кэшируем по
|
|
567
|
+
// size+mtime — повторные text-gen с той же нодой не перезагружают.
|
|
568
|
+
if (file.size > 400 * 1024) {
|
|
569
|
+
const cacheKey = `${ref.file}|${file.size}|${file.lastModified || 0}`;
|
|
570
|
+
let cdnUrl = _imageRefUploadCache.get(cacheKey);
|
|
571
|
+
if (!cdnUrl) {
|
|
572
|
+
const buf = await file.arrayBuffer();
|
|
573
|
+
const filename = ref.file.split('/').pop() || 'ref.jpg';
|
|
574
|
+
const r = await fetch('/api/upload', {
|
|
575
|
+
method: 'POST',
|
|
576
|
+
headers: { 'Content-Type': mime, 'X-File-Name': encodeURIComponent(filename) },
|
|
577
|
+
body: buf,
|
|
578
|
+
});
|
|
579
|
+
if (!r.ok) {
|
|
580
|
+
return { error: 'upload failed: HTTP ' + r.status };
|
|
581
|
+
}
|
|
582
|
+
const j = await r.json();
|
|
583
|
+
if (!j.url) return { error: 'upload вернул без url' };
|
|
584
|
+
cdnUrl = j.url;
|
|
585
|
+
_imageRefUploadCache.set(cacheKey, cdnUrl);
|
|
586
|
+
}
|
|
587
|
+
return { url: cdnUrl, size: file.size, mime };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Маленькая картинка → data URL (1 round-trip меньше).
|
|
558
591
|
let url;
|
|
559
592
|
if (file.type === mime) {
|
|
560
|
-
// type уже правильный — FileReader даст правильный data URL.
|
|
561
593
|
url = await new Promise((res, rej) => {
|
|
562
594
|
const r = new FileReader();
|
|
563
595
|
r.onload = () => res(r.result);
|
|
@@ -565,7 +597,6 @@ async function _imageRefToDataUrl(ref) {
|
|
|
565
597
|
r.readAsDataURL(file);
|
|
566
598
|
});
|
|
567
599
|
} else {
|
|
568
|
-
// type пустой/неправильный — пересобираем Blob с явным type.
|
|
569
600
|
const buf = await file.arrayBuffer();
|
|
570
601
|
const blob = new Blob([buf], { type: mime });
|
|
571
602
|
url = await new Promise((res, rej) => {
|
|
@@ -1931,7 +1962,7 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
|
|
|
1931
1962
|
const submitTimer = setInterval(() => {
|
|
1932
1963
|
if (state.jobs.get(node.id) !== job) return;
|
|
1933
1964
|
const sec = Math.round((Date.now() - submitStart) / 1000);
|
|
1934
|
-
logJob(node.id, `submit still pending... ${sec}s
|
|
1965
|
+
logJob(node.id, `submit still pending... ${sec}s`);
|
|
1935
1966
|
mutateNode(bKey, boardHandle, node.id, n => {
|
|
1936
1967
|
n.generated = { ...(n.generated || {}), state: `submitting (${sec}s)` };
|
|
1937
1968
|
}).catch(() => {});
|
|
@@ -2157,7 +2188,7 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
2157
2188
|
return;
|
|
2158
2189
|
}
|
|
2159
2190
|
if (pd.status === 'error') {
|
|
2160
|
-
logJob(nodeId, `
|
|
2191
|
+
logJob(nodeId, `provider error: ${pd.error || 'unknown'}`);
|
|
2161
2192
|
throw new Error(pd.error || 'generation failed');
|
|
2162
2193
|
}
|
|
2163
2194
|
if (pd.state) {
|
|
@@ -2174,7 +2205,7 @@ function updateNodeStateText(nodeId, stateKey) {
|
|
|
2174
2205
|
const root = canvas.querySelector(`.node[data-id="${nodeId}"] .gen-pending .state-text`);
|
|
2175
2206
|
if (!root) return false;
|
|
2176
2207
|
const LABELS = {
|
|
2177
|
-
'uploading-refs': 'Загружаю
|
|
2208
|
+
'uploading-refs': 'Загружаю референсы…',
|
|
2178
2209
|
'submitting': 'Отправляю задачу...',
|
|
2179
2210
|
'queued': 'В очереди...',
|
|
2180
2211
|
'waiting': 'В очереди...',
|
package/renderer/settings.js
CHANGED
|
@@ -203,7 +203,7 @@ async function renderNodeBody(node, body) {
|
|
|
203
203
|
wrap.className = 'gen-pending';
|
|
204
204
|
const sp = document.createElement('div'); sp.className = 'spinner lg';
|
|
205
205
|
const STATE_LABELS = {
|
|
206
|
-
'uploading-refs': 'Загружаю
|
|
206
|
+
'uploading-refs': 'Загружаю референсы…',
|
|
207
207
|
'submitting': 'Отправляю задачу...',
|
|
208
208
|
'queued': 'В очереди...',
|
|
209
209
|
'waiting': 'В очереди...',
|