kingkont 0.18.5 → 0.18.7
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/index.html +4 -2
- package/package.json +1 -1
- package/renderer/board.js +22 -5
- package/renderer/chat.js +101 -18
- package/renderer/notifyPanel.js +128 -10
- package/renderer/settings.js +67 -0
- package/renderer/styles.css +37 -6
package/index.html
CHANGED
|
@@ -63,8 +63,10 @@
|
|
|
63
63
|
</div>
|
|
64
64
|
<div class="sidebar-footer">
|
|
65
65
|
<div id="balancesAll" style="display:flex; flex-direction:column; gap:4px;"></div>
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
<!-- jobsInfo + hint убраны (юзер: «убери инфу 'в фоне' и 'перетаскивай
|
|
67
|
+
файлы на холст'»). Отступ сохранён через min-height в CSS — UI
|
|
68
|
+
не должен «прыгнуть» вверх. Активные процессы показываются на
|
|
69
|
+
кнопке 🔔 (см. notifyPanel.js _renderBellState). -->
|
|
68
70
|
</div>
|
|
69
71
|
</aside>
|
|
70
72
|
|
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -2461,11 +2461,16 @@ async function createNodeEl(node) {
|
|
|
2461
2461
|
el.dataset.id = node.id;
|
|
2462
2462
|
el.style.left = node.x + 'px';
|
|
2463
2463
|
el.style.top = node.y + 'px';
|
|
2464
|
-
// Label
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
//
|
|
2468
|
-
if (node.type
|
|
2464
|
+
// Label-ноды: высоту НЕ применяем (auto по контенту), но ширину — ДА,
|
|
2465
|
+
// если юзер явно её задал через label-width-handle. Это позволяет
|
|
2466
|
+
// сделать многострочный label («ширина задаёт перенос»). Без width
|
|
2467
|
+
// → auto-width по тексту (как раньше).
|
|
2468
|
+
if (node.type === 'label') {
|
|
2469
|
+
if (node.width) {
|
|
2470
|
+
el.style.width = node.width + 'px';
|
|
2471
|
+
el.dataset.fixedWidth = '1'; // CSS: разрешаем label-text занять 100%
|
|
2472
|
+
}
|
|
2473
|
+
} else {
|
|
2469
2474
|
if (node.width) el.style.width = node.width + 'px';
|
|
2470
2475
|
if (node.height) el.style.height = node.height + 'px';
|
|
2471
2476
|
}
|
|
@@ -2514,6 +2519,18 @@ async function createNodeEl(node) {
|
|
|
2514
2519
|
el.appendChild(rh);
|
|
2515
2520
|
attachResize(el, node, rh);
|
|
2516
2521
|
|
|
2522
|
+
// Label-ноды получают ВТОРОЙ хендл — на правом краю — для управления
|
|
2523
|
+
// только ШИРИНОЙ (юзер просил: «ноде лейбл позволь менять ширину
|
|
2524
|
+
// отдельно, чтобы менялось количество строк если текст длинный»).
|
|
2525
|
+
// Существующий .resize-handle (правый-нижний угол) меняет fontSize.
|
|
2526
|
+
if (node.type === 'label') {
|
|
2527
|
+
const wh = document.createElement('div');
|
|
2528
|
+
wh.className = 'label-width-handle';
|
|
2529
|
+
wh.title = 'Тяни — ширина (для переноса строк)';
|
|
2530
|
+
el.appendChild(wh);
|
|
2531
|
+
attachLabelWidthResize(el, node, wh);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2517
2534
|
const anchor = document.createElement('div');
|
|
2518
2535
|
anchor.className = 'anchor';
|
|
2519
2536
|
anchor.title = 'Тяни, чтобы сослаться или сгенерировать ноду со ссылкой';
|
package/renderer/chat.js
CHANGED
|
@@ -77,24 +77,63 @@
|
|
|
77
77
|
},
|
|
78
78
|
|
|
79
79
|
read_scene: {
|
|
80
|
-
description: 'Текущая доска: список нод (id, type, name?, prompt?, file?, x, y) и связи.',
|
|
80
|
+
description: 'Текущая доска: список нод (id, type, name?, prompt?, file?, x, y, width, height) и связи. ВАЖНО: x/y — top-left ноды; width/height — реальные размеры (либо измеренные ResizeObserver\'ом, либо дефолт по типу). Используй при размещении новых нод чтобы не накладывались. Также возвращается scene.bbox (общий rect используемой области) и canvasSize (где можно ставить — типичный canvas 6000×4000).',
|
|
81
81
|
params: '{}',
|
|
82
82
|
async handler() {
|
|
83
83
|
const b = state.currentBoard;
|
|
84
84
|
if (!b) throw new Error('доска не выбрана — используй select_scene');
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
85
|
+
// Дефолты width/height по типу — синхронизированы с _defaultH в board.js
|
|
86
|
+
// и overlap-detection в add_node ниже. Если ResizeObserver уже измерил
|
|
87
|
+
// ноду — используем кэш n.width/n.height (точнее).
|
|
88
|
+
function defW(t) { return (t === 'text' || t === 'label' || t === 'audio') ? 200 : 280; }
|
|
89
|
+
function defH(t) {
|
|
90
|
+
if (t === 'text') return 120;
|
|
91
|
+
if (t === 'audio') return 110;
|
|
92
|
+
if (t === 'image' || t === 'video') return 220;
|
|
93
|
+
if (t === 'label') return 60;
|
|
94
|
+
return 80;
|
|
95
|
+
}
|
|
96
|
+
const nodes = (b.metadata.nodes || []).map(n => {
|
|
97
|
+
const w = n.width || defW(n.type);
|
|
98
|
+
const h = n.height || defH(n.type);
|
|
99
|
+
return {
|
|
100
|
+
id: n.id,
|
|
101
|
+
type: n.type,
|
|
102
|
+
name: n.name || null,
|
|
103
|
+
x: n.x, y: n.y,
|
|
104
|
+
width: w, height: h,
|
|
105
|
+
// Удобно для LLM: rect = {left, top, right, bottom} — не надо
|
|
106
|
+
// считать самому при поиске свободного места.
|
|
107
|
+
rect: { left: n.x, top: n.y, right: n.x + w, bottom: n.y + h },
|
|
108
|
+
prompt: n.generated?.rawPrompt || n.generated?.prompt || null,
|
|
109
|
+
file: n.file || null,
|
|
110
|
+
status: n.status || (n.file ? 'done' : 'draft'),
|
|
111
|
+
modelKey: n.generated?.modelKey || null,
|
|
112
|
+
aspectRatio: n.generated?.aspectRatio || null,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
96
115
|
const connections = (b.metadata.connections || []).map(c => ({ from: c.from, to: c.to, toPort: c.toPort || null }));
|
|
97
|
-
|
|
116
|
+
// BBox всей сцены — даёт LLM понимание «где плотно, где свободно».
|
|
117
|
+
let bbox = null;
|
|
118
|
+
if (nodes.length) {
|
|
119
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
120
|
+
for (const n of nodes) {
|
|
121
|
+
if (n.rect.left < minX) minX = n.rect.left;
|
|
122
|
+
if (n.rect.top < minY) minY = n.rect.top;
|
|
123
|
+
if (n.rect.right > maxX) maxX = n.rect.right;
|
|
124
|
+
if (n.rect.bottom > maxY) maxY = n.rect.bottom;
|
|
125
|
+
}
|
|
126
|
+
bbox = { left: minX, top: minY, right: maxX, bottom: maxY,
|
|
127
|
+
width: maxX - minX, height: maxY - minY };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
kind: b.kind, name: b.name,
|
|
131
|
+
nodes, connections,
|
|
132
|
+
settings: b.metadata.settings || {},
|
|
133
|
+
bbox,
|
|
134
|
+
canvasSize: { width: 6000, height: 4000 },
|
|
135
|
+
hint: 'Размещая новую ноду, проверь чтобы её rect (x, y, x+width, y+height) не пересекался с существующими. Шаг 40-60px между нодами выглядит аккуратно.',
|
|
136
|
+
};
|
|
98
137
|
},
|
|
99
138
|
},
|
|
100
139
|
|
|
@@ -643,6 +682,17 @@
|
|
|
643
682
|
// Забираем историю с сервера (server-side state — source of truth).
|
|
644
683
|
// Если на сервере есть pending tool-loop — сразу стартуем polling.
|
|
645
684
|
async function loadHistoryFromCurrentProject() {
|
|
685
|
+
// Восстанавливаем «был ли чат открыт» в этом проекте. Юзер просил:
|
|
686
|
+
// «если в проекте был открыт чат — открывай его при переходе в проект».
|
|
687
|
+
// Делаем ДО загрузки истории — чтобы UI открылся сразу, история подтянется.
|
|
688
|
+
try {
|
|
689
|
+
const k = _chatOpenKey();
|
|
690
|
+
if (k && localStorage.getItem(k) === '1') {
|
|
691
|
+
ensureUI();
|
|
692
|
+
$('chatPanel').classList.remove('hidden');
|
|
693
|
+
// Не зовём _persistChatOpen(true) — флаг и так стоит.
|
|
694
|
+
}
|
|
695
|
+
} catch {}
|
|
646
696
|
const key = sessionKey();
|
|
647
697
|
if (!key) { history = []; renderHistory(); return; }
|
|
648
698
|
try {
|
|
@@ -1230,14 +1280,16 @@
|
|
|
1230
1280
|
// Финальный ответ — показать toast + system notification
|
|
1231
1281
|
// (даже если чат-панель скрыта).
|
|
1232
1282
|
if (m.event?.kind === 'final') {
|
|
1283
|
+
// target.chat — navigateToTarget откроет чат-панель вместо доски.
|
|
1284
|
+
const chatTarget = { target: { chat: true } };
|
|
1233
1285
|
if (m.event.error) {
|
|
1234
|
-
if (typeof showToast === 'function') showToast(`💬 KingKont: ⚠ ${m.event.error.slice(0, 80)}`, 'error');
|
|
1286
|
+
if (typeof showToast === 'function') showToast(`💬 KingKont: ⚠ ${m.event.error.slice(0, 80)}`, 'error', chatTarget);
|
|
1235
1287
|
if (typeof systemNotify === 'function' && document.hidden) {
|
|
1236
1288
|
systemNotify('KingKont chat', '⚠ ' + m.event.error.slice(0, 100), { tag: 'chat-final' }).catch(() => {});
|
|
1237
1289
|
}
|
|
1238
1290
|
} else if (m.event.text) {
|
|
1239
1291
|
const preview = m.event.text.length > 100 ? m.event.text.slice(0, 100) + '…' : m.event.text;
|
|
1240
|
-
if (typeof showToast === 'function') showToast(`💬 KingKont: ${preview}`, 'ok');
|
|
1292
|
+
if (typeof showToast === 'function') showToast(`💬 KingKont: ${preview}`, 'ok', chatTarget);
|
|
1241
1293
|
if (typeof systemNotify === 'function' && document.hidden) {
|
|
1242
1294
|
systemNotify('KingKont chat', preview, { tag: 'chat-final' }).catch(() => {});
|
|
1243
1295
|
}
|
|
@@ -1570,7 +1622,10 @@
|
|
|
1570
1622
|
e.preventDefault();
|
|
1571
1623
|
for (const f of files) await attachFileToChat(f);
|
|
1572
1624
|
});
|
|
1573
|
-
$('chatClose').addEventListener('click', () =>
|
|
1625
|
+
$('chatClose').addEventListener('click', () => {
|
|
1626
|
+
panel.classList.add('hidden');
|
|
1627
|
+
_persistChatOpen(false);
|
|
1628
|
+
});
|
|
1574
1629
|
$('chatClear').addEventListener('click', () => {
|
|
1575
1630
|
if (!confirm('Очистить историю чата?')) return;
|
|
1576
1631
|
history = [];
|
|
@@ -1591,11 +1646,29 @@
|
|
|
1591
1646
|
});
|
|
1592
1647
|
}
|
|
1593
1648
|
|
|
1649
|
+
// Per-project флаг «чат был открыт». При openFilm восстанавливаем
|
|
1650
|
+
// (юзер: «если в проекте был открыт чат — открывай его при переходе в проект»).
|
|
1651
|
+
function _chatOpenKey() {
|
|
1652
|
+
const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
|
|
1653
|
+
: state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
|
|
1654
|
+
return pk ? 'chatOpen:' + pk : null;
|
|
1655
|
+
}
|
|
1656
|
+
function _persistChatOpen(open) {
|
|
1657
|
+
const k = _chatOpenKey();
|
|
1658
|
+
if (!k) return;
|
|
1659
|
+
try {
|
|
1660
|
+
if (open) localStorage.setItem(k, '1');
|
|
1661
|
+
else localStorage.removeItem(k);
|
|
1662
|
+
} catch {}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1594
1665
|
function toggle() {
|
|
1595
1666
|
ensureUI();
|
|
1596
1667
|
const panel = $('chatPanel');
|
|
1597
1668
|
panel.classList.toggle('hidden');
|
|
1598
|
-
|
|
1669
|
+
const isOpen = !panel.classList.contains('hidden');
|
|
1670
|
+
_persistChatOpen(isOpen);
|
|
1671
|
+
if (isOpen) {
|
|
1599
1672
|
// Z-index сам разруливает: preview.collapsed → ниже чата (z=40 vs 45),
|
|
1600
1673
|
// preview открыт → поверх (z=50). Auto-collapse больше не нужен.
|
|
1601
1674
|
// Отрисовываем сохранённую историю при показе (на случай если она
|
|
@@ -1627,7 +1700,17 @@
|
|
|
1627
1700
|
// Public API.
|
|
1628
1701
|
window.kingChat = {
|
|
1629
1702
|
toggle,
|
|
1630
|
-
open: () => {
|
|
1703
|
+
open: () => {
|
|
1704
|
+
ensureUI();
|
|
1705
|
+
$('chatPanel').classList.remove('hidden');
|
|
1706
|
+
_persistChatOpen(true);
|
|
1707
|
+
renderHistory();
|
|
1708
|
+
renderContextRow();
|
|
1709
|
+
setTimeout(() => $('chatInput')?.focus(), 50);
|
|
1710
|
+
},
|
|
1711
|
+
// close() — implicit (board.js closeProject) — НЕ persist'им флаг,
|
|
1712
|
+
// иначе при следующем открытии проекта чат не восстановится.
|
|
1713
|
+
// Юзерское × persists через свой handler ниже (chatClose).
|
|
1631
1714
|
close: () => $('chatPanel')?.classList.add('hidden'),
|
|
1632
1715
|
send,
|
|
1633
1716
|
// User-clear: стираем И на сервере (юзер явно нажал ⌫).
|
package/renderer/notifyPanel.js
CHANGED
|
@@ -55,21 +55,36 @@
|
|
|
55
55
|
`;
|
|
56
56
|
document.head.appendChild(style);
|
|
57
57
|
}
|
|
58
|
-
//
|
|
59
|
-
//
|
|
58
|
+
// Контейнер: 🔔-кнопка + описание справа. Раньше была только кнопка,
|
|
59
|
+
// теперь юзер хочет видеть «что происходит в фоне» рядом с ней
|
|
60
|
+
// (info про активные генерации убрана из sidebar-footer).
|
|
61
|
+
const wrap = document.createElement('div');
|
|
62
|
+
wrap.id = 'notifyBtnWrap';
|
|
63
|
+
wrap.style.cssText = 'position:fixed; bottom:12px; left:12px; z-index:9998; display:flex; align-items:center; gap:8px; pointer-events:none;';
|
|
64
|
+
document.body.appendChild(wrap);
|
|
65
|
+
|
|
60
66
|
const btn = document.createElement('button');
|
|
61
67
|
btn.id = 'notifyBtn';
|
|
62
|
-
btn.title = 'События (генерации, чат, ошибки)';
|
|
63
|
-
btn.style.cssText = 'position:
|
|
64
|
-
btn.innerHTML = '
|
|
65
|
-
|
|
68
|
+
btn.title = 'События (генерации, чат, ошибки) — клик чтобы открыть';
|
|
69
|
+
btn.style.cssText = 'position:relative; background:rgba(30,30,40,0.85); border:1px solid #444; color:#ccc; font-size:14px; width:32px; height:32px; border-radius:50%; cursor:pointer; backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; transition:background 0.2s; pointer-events:auto; flex-shrink:0;';
|
|
70
|
+
btn.innerHTML = '<span id="notifyBtnIcon">🔔</span>';
|
|
71
|
+
wrap.appendChild(btn);
|
|
66
72
|
btn.addEventListener('click', toggle);
|
|
67
|
-
// Бейдж со счётчиком unread.
|
|
73
|
+
// Бейдж со счётчиком unread (события, не задачи).
|
|
68
74
|
const badge = document.createElement('span');
|
|
69
75
|
badge.id = 'notifyBadge';
|
|
70
76
|
badge.style.cssText = 'position:absolute; top:-4px; right:-4px; background:#e33377; color:#fff; font-size:9px; min-width:14px; height:14px; line-height:14px; text-align:center; border-radius:8px; padding:0 4px; display:none; pointer-events:none;';
|
|
71
77
|
btn.appendChild(badge);
|
|
72
78
|
|
|
79
|
+
// Описание текущей фоновой активности — справа от кнопки.
|
|
80
|
+
// Показывается когда есть активные jobs; иначе hidden.
|
|
81
|
+
const desc = document.createElement('div');
|
|
82
|
+
desc.id = 'notifyActivityDesc';
|
|
83
|
+
desc.style.cssText = 'background:rgba(30,30,40,0.85); border:1px solid #444; color:#cde; font-size:11px; line-height:1.3; padding:5px 10px; border-radius:14px; backdrop-filter:blur(4px); max-width:380px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:none; pointer-events:auto; cursor:pointer;';
|
|
84
|
+
desc.title = 'Текущие фоновые задачи (клик — раскрыть события)';
|
|
85
|
+
desc.addEventListener('click', () => setOpen(true, 'manual'));
|
|
86
|
+
wrap.appendChild(desc);
|
|
87
|
+
|
|
73
88
|
// Панель — bottom-left как и кнопка.
|
|
74
89
|
const panel = document.createElement('div');
|
|
75
90
|
panel.id = 'notifyPanel';
|
|
@@ -217,6 +232,7 @@
|
|
|
217
232
|
render();
|
|
218
233
|
}
|
|
219
234
|
_pulseBell();
|
|
235
|
+
_renderBellState();
|
|
220
236
|
// Авто-открытие панели на 3.5s — юзер видит событие сразу, потом
|
|
221
237
|
// панель прячется. Если юзер сам открыл — не трогаем (проверка в
|
|
222
238
|
// openAuto). Каждое новое событие сбрасывает таймер.
|
|
@@ -232,6 +248,91 @@
|
|
|
232
248
|
void btn.offsetWidth;
|
|
233
249
|
btn.classList.add('pulsing');
|
|
234
250
|
}
|
|
251
|
+
|
|
252
|
+
// === Bell-state: счётчик активных задач + описание ===
|
|
253
|
+
// Юзер: «количество актуальных процессов выводи на месте колокольчика,
|
|
254
|
+
// справа от него описывай что происходит». Источник — state.jobs (local
|
|
255
|
+
// pollers) + window.bgJobsAll() (server-side jobs hub). Берём максимум
|
|
256
|
+
// (server может видеть больше, если renderer недавно открылся).
|
|
257
|
+
function _renderBellState() {
|
|
258
|
+
const icon = $('notifyBtnIcon');
|
|
259
|
+
const desc = $('notifyActivityDesc');
|
|
260
|
+
if (!icon || !desc) return;
|
|
261
|
+
// Считаем АКТИВНЫЕ задачи только для ТЕКУЩЕГО проекта (если открыт).
|
|
262
|
+
// Иначе welcome-экран показал бы все задачи всех проектов — confusing.
|
|
263
|
+
const items = _collectActiveJobs();
|
|
264
|
+
if (!items.length) {
|
|
265
|
+
icon.textContent = '🔔';
|
|
266
|
+
desc.style.display = 'none';
|
|
267
|
+
desc.textContent = '';
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// На месте колокольчика — число.
|
|
271
|
+
icon.textContent = String(items.length);
|
|
272
|
+
icon.style.fontSize = items.length > 9 ? '11px' : '12px';
|
|
273
|
+
icon.style.fontWeight = '700';
|
|
274
|
+
// Справа — описание. Формат: «🖼 image «name» + N ещё», или, если
|
|
275
|
+
// одна — полное описание; если несколько типов — kind-counters.
|
|
276
|
+
desc.textContent = _describeActivity(items);
|
|
277
|
+
desc.style.display = '';
|
|
278
|
+
}
|
|
279
|
+
function _collectActiveJobs() {
|
|
280
|
+
const out = [];
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
// 1) state.jobs (local pollers) — это in-flight для текущего board.
|
|
283
|
+
if (window.state?.jobs) {
|
|
284
|
+
for (const [nodeId, j] of window.state.jobs.entries?.() || []) {
|
|
285
|
+
if (seen.has(nodeId)) continue;
|
|
286
|
+
seen.add(nodeId);
|
|
287
|
+
out.push({ nodeId, kind: j.kind || 'gen', name: _resolveNodeName(nodeId) });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// 2) bgJobsAll (localStorage source) — для текущего проекта.
|
|
291
|
+
const pk = _currentProjectKey();
|
|
292
|
+
if (pk && typeof window.bgJobsAll === 'function') {
|
|
293
|
+
const all = window.bgJobsAll();
|
|
294
|
+
const list = all[pk] || [];
|
|
295
|
+
for (const j of list) {
|
|
296
|
+
if (seen.has(j.nodeId)) continue;
|
|
297
|
+
seen.add(j.nodeId);
|
|
298
|
+
out.push({ nodeId: j.nodeId, kind: j.kind || 'gen', name: j.name || null });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
function _currentProjectKey() {
|
|
304
|
+
const s = window.state;
|
|
305
|
+
if (!s) return null;
|
|
306
|
+
if (s.cloudProjectId) return 'cloud:' + s.cloudProjectId;
|
|
307
|
+
if (s.filmHandle?.name) return 'folder:' + s.filmHandle.name;
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
function _resolveNodeName(nodeId) {
|
|
311
|
+
const nodes = window.state?.currentBoard?.metadata?.nodes;
|
|
312
|
+
if (!Array.isArray(nodes)) return null;
|
|
313
|
+
const n = nodes.find(x => x.id === nodeId);
|
|
314
|
+
return n?.name || null;
|
|
315
|
+
}
|
|
316
|
+
function _describeActivity(items) {
|
|
317
|
+
const KIND_ICON = { image: '🖼', video: '🎬', text: '📝', audio: '🎙', chat: '💬' };
|
|
318
|
+
if (items.length === 1) {
|
|
319
|
+
const it = items[0];
|
|
320
|
+
const ic = KIND_ICON[it.kind] || '⚙';
|
|
321
|
+
const label = it.name ? `«${it.name}»` : '(без имени)';
|
|
322
|
+
return `${ic} ${it.kind} ${label}`;
|
|
323
|
+
}
|
|
324
|
+
// Группируем по kind для краткой сводки.
|
|
325
|
+
const counts = {};
|
|
326
|
+
for (const it of items) counts[it.kind] = (counts[it.kind] || 0) + 1;
|
|
327
|
+
const parts = Object.entries(counts).map(([k, n]) => {
|
|
328
|
+
const ic = KIND_ICON[k] || '⚙';
|
|
329
|
+
return `${ic} ${k}×${n}`;
|
|
330
|
+
});
|
|
331
|
+
return parts.join(' · ');
|
|
332
|
+
}
|
|
333
|
+
// Public, чтобы generate.js / state.js / etc могли явно ткнуть rerender.
|
|
334
|
+
window.notifyPanel = window.notifyPanel || {};
|
|
335
|
+
// (расширим объект публичного API ниже — здесь просто резерв.)
|
|
235
336
|
// Auto-flash: открыть в auto-режиме и закрыть через таймер. Срабатывает
|
|
236
337
|
// на КАЖДОЕ addEvent. Если юзер уже manual'но открыл — открытие no-op'ится.
|
|
237
338
|
let _autoFlashT = null;
|
|
@@ -245,10 +346,18 @@
|
|
|
245
346
|
}, 5000);
|
|
246
347
|
}
|
|
247
348
|
|
|
248
|
-
// Navigate from notification to scene/node
|
|
249
|
-
// target: {projectKey, boardKey, nodeId?}
|
|
349
|
+
// Navigate from notification to scene/node — или в чат для chat-event'ов.
|
|
350
|
+
// target: {projectKey, boardKey, nodeId?} | {chat: true}
|
|
250
351
|
async function navigateToTarget(target) {
|
|
251
|
-
if (!target
|
|
352
|
+
if (!target) return;
|
|
353
|
+
// Чат-нотификация — открыть чат-панель (а не доску). Юзер: «когда
|
|
354
|
+
// уведомление приходит из чата — клик по уведомлению должен вести в чат».
|
|
355
|
+
if (target.chat) {
|
|
356
|
+
setOpen(false);
|
|
357
|
+
if (window.kingChat?.open) window.kingChat.open();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!target.projectKey) return;
|
|
252
361
|
setOpen(false);
|
|
253
362
|
const [pkType, pkId] = String(target.projectKey).split(':', 2);
|
|
254
363
|
const currentKey = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
|
|
@@ -333,6 +442,15 @@
|
|
|
333
442
|
q.length = 0;
|
|
334
443
|
}
|
|
335
444
|
} catch {}
|
|
445
|
+
// Bell-state: первичная отрисовка + подписка на bgjobs:changed.
|
|
446
|
+
// Этот event кидают bgJobStart/bgJobEnd (state.js) — счётчик и
|
|
447
|
+
// описание мгновенно обновляются.
|
|
448
|
+
_renderBellState();
|
|
449
|
+
window.addEventListener('bgjobs:changed', _renderBellState);
|
|
450
|
+
// Selection / current-board меняются без event'а — лёгкий poll
|
|
451
|
+
// даёт мгновенное название ноды («когда state.jobs обновился, но
|
|
452
|
+
// bgjobs:changed не прилетел»).
|
|
453
|
+
setInterval(_renderBellState, 1500);
|
|
336
454
|
// WS-канал jobs:all для start/end/done/failed events. Connect к /ws.
|
|
337
455
|
function _connect() {
|
|
338
456
|
let ws;
|
package/renderer/settings.js
CHANGED
|
@@ -746,6 +746,48 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
|
|
|
746
746
|
}, 50);
|
|
747
747
|
}
|
|
748
748
|
|
|
749
|
+
// Width-only resize для label-нод. Юзер тянет хендл на правом краю —
|
|
750
|
+
// устанавливаем node.width, label-text оборачивается. Высота auto.
|
|
751
|
+
// Двойной клик по хендлу — снять width (вернуть auto-width по тексту).
|
|
752
|
+
function attachLabelWidthResize(el, node, handle) {
|
|
753
|
+
handle.addEventListener('mousedown', e => {
|
|
754
|
+
e.preventDefault();
|
|
755
|
+
e.stopPropagation();
|
|
756
|
+
const startX = e.clientX;
|
|
757
|
+
const startW = el.offsetWidth;
|
|
758
|
+
const labelText = el.querySelector('.label-text');
|
|
759
|
+
const onMove = ev => {
|
|
760
|
+
const dx = (ev.clientX - startX) / state.zoom;
|
|
761
|
+
const newW = Math.max(80, Math.min(2000, startW + dx));
|
|
762
|
+
node.width = Math.round(newW);
|
|
763
|
+
el.style.width = node.width + 'px';
|
|
764
|
+
el.dataset.fixedWidth = '1';
|
|
765
|
+
// Force label-text to fill — иначе inline-block остаётся узким.
|
|
766
|
+
if (labelText) labelText.style.width = '100%';
|
|
767
|
+
renderConnections();
|
|
768
|
+
};
|
|
769
|
+
const onUp = () => {
|
|
770
|
+
document.removeEventListener('mousemove', onMove);
|
|
771
|
+
document.removeEventListener('mouseup', onUp);
|
|
772
|
+
scheduleSave();
|
|
773
|
+
};
|
|
774
|
+
document.addEventListener('mousemove', onMove);
|
|
775
|
+
document.addEventListener('mouseup', onUp);
|
|
776
|
+
});
|
|
777
|
+
// dblclick — сбросить ширину (вернуть auto-size).
|
|
778
|
+
handle.addEventListener('dblclick', e => {
|
|
779
|
+
e.preventDefault();
|
|
780
|
+
e.stopPropagation();
|
|
781
|
+
delete node.width;
|
|
782
|
+
el.style.width = '';
|
|
783
|
+
delete el.dataset.fixedWidth;
|
|
784
|
+
const labelText = el.querySelector('.label-text');
|
|
785
|
+
if (labelText) labelText.style.width = '';
|
|
786
|
+
renderConnections();
|
|
787
|
+
scheduleSave();
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
749
791
|
function attachResize(el, node, handle) {
|
|
750
792
|
handle.addEventListener('mousedown', e => {
|
|
751
793
|
e.preventDefault();
|
|
@@ -1062,6 +1104,22 @@ async function deleteNode(node, el) {
|
|
|
1062
1104
|
const ok = confirm(`Эта нода используется в таймлайне (${timelineRefs} клип${timelineRefs === 1 ? '' : 'ов'}). Удалить вместе с клипами?`);
|
|
1063
1105
|
if (!ok) return;
|
|
1064
1106
|
}
|
|
1107
|
+
// Останавливаем активные генерации для этой ноды (юзер: «если нода
|
|
1108
|
+
// удаляется — останавливай связанные с ней задания»). Иначе server
|
|
1109
|
+
// продолжал бы поллить провайдера, скачивал бы результат и пытался
|
|
1110
|
+
// записать файл к удалённой ноде → битое состояние.
|
|
1111
|
+
// 1) Local poll loop — выйдет на следующей итерации (state.jobs.delete).
|
|
1112
|
+
if (state.jobs?.has(node.id)) {
|
|
1113
|
+
state.jobs.delete(node.id);
|
|
1114
|
+
if (typeof updateJobsBadge === 'function') updateJobsBadge();
|
|
1115
|
+
}
|
|
1116
|
+
// 2) Server-side jobsHub — explicit end, чтобы _startPoller остановился
|
|
1117
|
+
// сразу, не ждал auto-end через 60s.
|
|
1118
|
+
if (typeof window.bgJobEnd === 'function') {
|
|
1119
|
+
const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
|
|
1120
|
+
: state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
|
|
1121
|
+
if (pk) window.bgJobEnd(node.id, pk);
|
|
1122
|
+
}
|
|
1065
1123
|
// Snapshot ДО мутации — чтобы undo вернул всё как было.
|
|
1066
1124
|
const snap = captureScene();
|
|
1067
1125
|
const movedFiles = [];
|
|
@@ -1130,6 +1188,15 @@ async function deleteSelectedNodes() {
|
|
|
1130
1188
|
if (!confirm(`Удалить ${nodesToDelete.length} нод?`)) return;
|
|
1131
1189
|
}
|
|
1132
1190
|
|
|
1191
|
+
// Останавливаем активные генерации для каждой удаляемой ноды (см. deleteNode).
|
|
1192
|
+
const _pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
|
|
1193
|
+
: state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
|
|
1194
|
+
for (const n of nodesToDelete) {
|
|
1195
|
+
if (state.jobs?.has(n.id)) state.jobs.delete(n.id);
|
|
1196
|
+
if (_pk && typeof window.bgJobEnd === 'function') window.bgJobEnd(n.id, _pk);
|
|
1197
|
+
}
|
|
1198
|
+
if (typeof updateJobsBadge === 'function') updateJobsBadge();
|
|
1199
|
+
|
|
1133
1200
|
// Снимок сцены ДО — для undo
|
|
1134
1201
|
const snapshot = JSON.stringify({
|
|
1135
1202
|
nodes: board.metadata.nodes,
|
package/renderer/styles.css
CHANGED
|
@@ -123,12 +123,12 @@
|
|
|
123
123
|
border-top: 1px solid #333; padding: 10px 12px;
|
|
124
124
|
display: flex; flex-direction: column; gap: 6px;
|
|
125
125
|
font-size: 11px; color: #777;
|
|
126
|
+
/* Отступ сохранён даже когда jobsInfo и hint убраны — иначе sidebar
|
|
127
|
+
«прыгнет» вверх. 56px ≈ высота прежних двух строк + padding. */
|
|
128
|
+
min-height: 56px;
|
|
126
129
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
событий (position:fixed; left:12px; bottom:12px) их не перекрывала. */
|
|
130
|
-
.sidebar-footer .hint { color: #777; font-size: 11px; line-height: 1.4; padding-left: 44px; }
|
|
131
|
-
.sidebar-footer .jobs-info { color: #aaccdd; font-size: 11px; padding-left: 44px; }
|
|
130
|
+
.sidebar-footer .hint { color: #777; font-size: 11px; line-height: 1.4; }
|
|
131
|
+
.sidebar-footer .jobs-info { color: #aaccdd; font-size: 11px; }
|
|
132
132
|
.sidebar-footer .balance-info {
|
|
133
133
|
display: flex; align-items: center; gap: 6px; font-size: 11px;
|
|
134
134
|
color: #c4c4c4; padding: 4px 8px; background: #2a2a2a;
|
|
@@ -519,6 +519,12 @@
|
|
|
519
519
|
overflow: hidden;
|
|
520
520
|
}
|
|
521
521
|
.welcome-card-thumb {
|
|
522
|
+
/* position:absolute + inset:0 — заполняем всю карточку (4:3).
|
|
523
|
+
БАДЖИ внутри (cloud/bg) тоже absolute → этот thumb работает как
|
|
524
|
+
positioning context для них. РАНЬШЕ ниже было `.welcome-card-thumb
|
|
525
|
+
{ position: relative; }` — оно переопределяло absolute → thumb
|
|
526
|
+
shrink'ался до размера контента (символ «+» / «☁» / «🎬») и
|
|
527
|
+
прибивался к верху карточки. Слились в один rule. */
|
|
522
528
|
position: absolute; inset: 0;
|
|
523
529
|
background: #1a1a1a;
|
|
524
530
|
display: flex; align-items: center; justify-content: center;
|
|
@@ -528,7 +534,6 @@
|
|
|
528
534
|
.welcome-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
529
535
|
/* ☁-бейдж в углу обложки облачного проекта. Делает cloud/folder-проекты
|
|
530
536
|
визуально различимыми в общем grid'е (без необходимости второго рядa). */
|
|
531
|
-
.welcome-card-thumb { position: relative; }
|
|
532
537
|
.welcome-card-cloud-badge {
|
|
533
538
|
position: absolute; right: 6px; bottom: 6px;
|
|
534
539
|
background: rgba(20, 30, 50, 0.85); color: #9cf;
|
|
@@ -764,6 +769,32 @@
|
|
|
764
769
|
}
|
|
765
770
|
.node.label-node:hover .resize-handle,
|
|
766
771
|
.node.label-node:focus-within .resize-handle { opacity: 1; }
|
|
772
|
+
/* Width-only handle на правом краю — отдельно от font-size handle.
|
|
773
|
+
Тянуть → меняется ширина label, текст переносится. dblclick →
|
|
774
|
+
сбросить ширину обратно к auto. */
|
|
775
|
+
.node.label-node .label-width-handle {
|
|
776
|
+
position: absolute;
|
|
777
|
+
right: -3px; top: 50%;
|
|
778
|
+
transform: translateY(-50%);
|
|
779
|
+
width: 6px; height: 36px;
|
|
780
|
+
background: rgba(90,168,255,0.7);
|
|
781
|
+
border: 1px solid rgba(255,255,255,0.35);
|
|
782
|
+
border-radius: 3px;
|
|
783
|
+
cursor: ew-resize;
|
|
784
|
+
opacity: 0; transition: opacity 0.15s;
|
|
785
|
+
z-index: 11;
|
|
786
|
+
}
|
|
787
|
+
.node.label-node:hover .label-width-handle,
|
|
788
|
+
.node.label-node:focus-within .label-width-handle { opacity: 1; }
|
|
789
|
+
/* Когда label имеет explicit width (data-fixed-width="1") — внутренний
|
|
790
|
+
label-text должен ЗАПОЛНЯТЬ родителя, иначе inline-block остаётся
|
|
791
|
+
узким и текст не переносится. Сбрасываем min/max-width inner'a. */
|
|
792
|
+
.node.label-node[data-fixed-width="1"] .label-text {
|
|
793
|
+
width: 100%;
|
|
794
|
+
min-width: 0;
|
|
795
|
+
max-width: none;
|
|
796
|
+
box-sizing: border-box;
|
|
797
|
+
}
|
|
767
798
|
|
|
768
799
|
/* Body — wrapper без визуального вклада. */
|
|
769
800
|
.node.label-node .node-body {
|