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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.20.7",
3
+ "version": "0.20.8",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -17,7 +17,19 @@ const emptyState = $('emptyState');
17
17
 
18
18
  // =================== Init ===================
19
19
  window.addEventListener('DOMContentLoaded', async () => {
20
- if (!('showDirectoryPicker' in window)) { showUnsupported(); return; }
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
- const openCard = document.createElement('div');
715
- openCard.className = 'welcome-card open-card';
716
- const openThumb = document.createElement('div');
717
- openThumb.className = 'welcome-card-thumb';
718
- openThumb.textContent = '+';
719
- const openMeta = document.createElement('div');
720
- openMeta.className = 'welcome-card-meta';
721
- const openName = document.createElement('div');
722
- openName.className = 'welcome-card-name';
723
- openName.textContent = 'Открыть проект';
724
- const openSub = document.createElement('div');
725
- openSub.className = 'welcome-card-ts';
726
- openSub.textContent = 'выбрать папку…';
727
- openMeta.append(openName, openSub);
728
- openCard.append(openThumb, openMeta);
729
- openCard.addEventListener('click', () => $('pickRoot').click());
730
- grid.appendChild(openCard);
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
- cloudBadge.textContent = '☁';
1163
- cloudBadge.title = 'Облачный проект';
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) {
@@ -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 (KIE может тормозить)`);
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, `KIE error: ${pd.error || 'unknown'}`);
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': 'Загружаю референсы в KIE...',
2208
+ 'uploading-refs': 'Загружаю референсы…',
2178
2209
  'submitting': 'Отправляю задачу...',
2179
2210
  'queued': 'В очереди...',
2180
2211
  'waiting': 'В очереди...',
@@ -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': 'Загружаю референсы в KIE...',
206
+ 'uploading-refs': 'Загружаю референсы…',
207
207
  'submitting': 'Отправляю задачу...',
208
208
  'queued': 'В очереди...',
209
209
  'waiting': 'В очереди...',