kingkont 0.16.0 → 0.16.2
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 +23 -5
- package/package.json +1 -1
- package/renderer/board.js +53 -14
- package/renderer/chat.js +52 -5
- package/renderer/generate.js +12 -5
- package/renderer/state.js +39 -16
- package/renderer/styles.css +41 -5
package/lib/providers.js
CHANGED
|
@@ -602,16 +602,34 @@ async function generateMusic(args) {
|
|
|
602
602
|
const { prompt, durationMs, settings: s } = args;
|
|
603
603
|
if (!prompt) throw new Error('нужен prompt');
|
|
604
604
|
|
|
605
|
-
// Приоритет direct ElevenLabs если включён + есть
|
|
606
|
-
// быть кастомные саб-настройки и платный ElevenLabs (зачем тратить
|
|
607
|
-
// Chatium-кредиты). Аналогичная логика в generateTts/generateSfx.
|
|
605
|
+
// Приоритет direct ElevenLabs если включён + есть ключ.
|
|
608
606
|
const directElevenAvailable = s.useElevenlabs && process.env.ELEVENLABS_API_KEY;
|
|
609
607
|
|
|
608
|
+
// Пробуем Chatium первым ТОЛЬКО если direct ElevenLabs не доступен.
|
|
609
|
+
// Если Chatium упал на музыке — auto-fallback на direct ElevenLabs если он
|
|
610
|
+
// тоже включён (бывало: Chatium-side music broken, у юзера только Eleven
|
|
611
|
+
// через Chatium → 500 → надо retry direct).
|
|
610
612
|
if (s.useChatium && s.chatium?.token && s.chatium?.base && !directElevenAvailable) {
|
|
611
|
-
|
|
613
|
+
try {
|
|
614
|
+
return await audioViaChatium(s, { kind: 'music', prompt, durationMs });
|
|
615
|
+
} catch (e) {
|
|
616
|
+
// Если есть direct Eleven (даже без env-key, попробуем s.elevenKey) —
|
|
617
|
+
// используем его как fallback.
|
|
618
|
+
if (s.useElevenlabs && (process.env.ELEVENLABS_API_KEY || s.elevenKey)) {
|
|
619
|
+
console.warn('[music] Chatium failed, falling back to direct ElevenLabs:', e?.message);
|
|
620
|
+
// Промосим в env если задан в settings но не в env (race с
|
|
621
|
+
// applySettingsToEnv после старта сервера).
|
|
622
|
+
if (!process.env.ELEVENLABS_API_KEY && s.elevenKey) {
|
|
623
|
+
process.env.ELEVENLABS_API_KEY = s.elevenKey;
|
|
624
|
+
}
|
|
625
|
+
// Падаем в direct-eleven path ниже.
|
|
626
|
+
} else {
|
|
627
|
+
throw new Error(`Музыка через KingKont недоступна: ${e.message?.slice(0, 200)}. Включи прямой ElevenLabs ключ в Настройках.`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
612
630
|
}
|
|
613
631
|
if (!s.useElevenlabs) throw new Error('Войдите в KingKont или ElevenLabs для аудио.');
|
|
614
|
-
const key = process.env.ELEVENLABS_API_KEY;
|
|
632
|
+
const key = process.env.ELEVENLABS_API_KEY || s.elevenKey;
|
|
615
633
|
if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
|
|
616
634
|
const body = { prompt };
|
|
617
635
|
if (durationMs) body.music_length_ms = +durationMs;
|
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -101,14 +101,17 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|
|
101
101
|
setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
|
|
102
102
|
|
|
103
103
|
// Тихий autoload: первый recent с granted-permission (если есть).
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
104
|
+
// skipAutoload работает по двум флагам:
|
|
105
|
+
// - welcomeOnNextStart (одноразовый, ставится в closeProject)
|
|
106
|
+
// - lastLocation === 'welcome' (постоянный, ставится при показе welcome)
|
|
107
|
+
// Без второго: после Cmd+R на welcome flag сбрасывался, и второй Cmd+R
|
|
108
|
+
// открывал последний проект. Теперь lastLocation сохраняется пока юзер
|
|
109
|
+
// не открыл проект.
|
|
110
|
+
const skipAutoload = localStorage.getItem('welcomeOnNextStart') === '1'
|
|
111
|
+
|| localStorage.getItem('lastLocation') === 'welcome';
|
|
109
112
|
if (skipAutoload) {
|
|
110
113
|
localStorage.removeItem('welcomeOnNextStart');
|
|
111
|
-
vlog('info', 'restore: skipped (
|
|
114
|
+
vlog('info', 'restore: skipped (welcome state persisted)');
|
|
112
115
|
} else {
|
|
113
116
|
try {
|
|
114
117
|
const recents = await getRecents();
|
|
@@ -458,8 +461,21 @@ async function renderWelcomeRecents() {
|
|
|
458
461
|
if (!grid || !wrap) return;
|
|
459
462
|
grid.innerHTML = '';
|
|
460
463
|
wrap.style.display = '';
|
|
464
|
+
// Запоминаем что юзер на welcome — Cmd+R должен оставить здесь.
|
|
465
|
+
try { localStorage.setItem('lastLocation', 'welcome'); } catch {}
|
|
461
466
|
// Identity-pill в правом верхнем — обновляем при каждом показе welcome.
|
|
462
467
|
renderWelcomeIdentity().catch(() => {});
|
|
468
|
+
// Server-side bg-jobs cache: обновим в фоне (если изменится — снова
|
|
469
|
+
// re-render через bgjobs:changed event). Без infinite loop: проверяем
|
|
470
|
+
// что состояние реально поменялось.
|
|
471
|
+
(async () => {
|
|
472
|
+
const before = JSON.stringify(_bgServerCache);
|
|
473
|
+
await refreshBgServerCache();
|
|
474
|
+
const after = JSON.stringify(_bgServerCache);
|
|
475
|
+
if (before !== after && !state.filmHandle) {
|
|
476
|
+
window.dispatchEvent(new CustomEvent('bgjobs:changed'));
|
|
477
|
+
}
|
|
478
|
+
})().catch(() => {});
|
|
463
479
|
const list = await getRecents();
|
|
464
480
|
|
|
465
481
|
// Первой картой — «Открыть проект». Кликается → дёргает скрытый
|
|
@@ -603,10 +619,25 @@ function _ru_jobs(n) {
|
|
|
603
619
|
if (n10 >= 2 && n10 <= 4) return 'генерации';
|
|
604
620
|
return 'генераций';
|
|
605
621
|
}
|
|
622
|
+
// Server-side cache всех bg-jobs. Source of truth — server jobsHub
|
|
623
|
+
// (через GET /api/jobs или WS push). localStorage остаётся только как
|
|
624
|
+
// fallback и для оптимистичных обновлений на самом клиенте.
|
|
625
|
+
let _bgServerCache = {}; // projectKey → [{jobId, kind, name, startedAt}]
|
|
626
|
+
async function refreshBgServerCache() {
|
|
627
|
+
try {
|
|
628
|
+
const r = await fetch('/api/jobs');
|
|
629
|
+
if (!r.ok) return;
|
|
630
|
+
const d = await r.json();
|
|
631
|
+
_bgServerCache = d?.all || {};
|
|
632
|
+
} catch {}
|
|
633
|
+
}
|
|
634
|
+
|
|
606
635
|
// Live-update welcome когда bg-jobs меняются (start/end). Перерисовываем
|
|
607
636
|
// только если открыт welcome (нет проекта), иначе UI не виден.
|
|
608
637
|
window.addEventListener('bgjobs:changed', () => {
|
|
609
|
-
if (!state.filmHandle)
|
|
638
|
+
if (!state.filmHandle) {
|
|
639
|
+
refreshBgServerCache().then(() => renderWelcomeRecents().catch(() => {}));
|
|
640
|
+
}
|
|
610
641
|
});
|
|
611
642
|
|
|
612
643
|
// WebSocket подписка на jobs:all — server push'ит при изменениях ЛЮБЫХ
|
|
@@ -625,7 +656,11 @@ function _connectWelcomeJobsWS() {
|
|
|
625
656
|
_welcomeWS.onmessage = (e) => {
|
|
626
657
|
let m; try { m = JSON.parse(e.data); } catch { return; }
|
|
627
658
|
if (m?.type === 'event' && m.channel === 'jobs:all') {
|
|
628
|
-
|
|
659
|
+
// Refresh cache from server first (m.event.list содержит свежий
|
|
660
|
+
// список для конкретного projectKey, но проще сделать GET /api/jobs).
|
|
661
|
+
refreshBgServerCache().then(() => {
|
|
662
|
+
if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
|
|
663
|
+
});
|
|
629
664
|
}
|
|
630
665
|
};
|
|
631
666
|
_welcomeWS.onclose = () => { _welcomeWS = null; setTimeout(_connectWelcomeJobsWS, 5000); };
|
|
@@ -647,9 +682,8 @@ function makeRecentWelcomeCard(r) {
|
|
|
647
682
|
img.onload = () => setTimeout(() => URL.revokeObjectURL(img.src), 60_000);
|
|
648
683
|
thumb.appendChild(img);
|
|
649
684
|
} else thumb.textContent = '🎬';
|
|
650
|
-
// Бейдж «N в фоне»
|
|
651
|
-
|
|
652
|
-
const bgList = (typeof bgJobsAll === 'function' ? bgJobsAll() : {})['folder:' + r.name];
|
|
685
|
+
// Бейдж «N в фоне» — server-side jobsHub source-of-truth (см. _bgServerCache).
|
|
686
|
+
const bgList = _bgServerCache['folder:' + r.name];
|
|
653
687
|
if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
|
|
654
688
|
const meta = document.createElement('div');
|
|
655
689
|
meta.className = 'welcome-card-meta';
|
|
@@ -804,8 +838,8 @@ function makeCloudWelcomeCard(p) {
|
|
|
804
838
|
cloudBadge.textContent = '☁';
|
|
805
839
|
cloudBadge.title = 'Облачный проект';
|
|
806
840
|
thumb.appendChild(cloudBadge);
|
|
807
|
-
// Бейдж «N в фоне»
|
|
808
|
-
const bgList =
|
|
841
|
+
// Бейдж «N в фоне» — server-side jobsHub source-of-truth.
|
|
842
|
+
const bgList = _bgServerCache['cloud:' + p.id];
|
|
809
843
|
if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
|
|
810
844
|
|
|
811
845
|
const meta = document.createElement('div');
|
|
@@ -1060,6 +1094,8 @@ async function openFilm(handle) {
|
|
|
1060
1094
|
state.filmHandle = handle;
|
|
1061
1095
|
state.currentBoard = null;
|
|
1062
1096
|
document.body.classList.remove('no-project');
|
|
1097
|
+
// Запоминаем что юзер сейчас в проекте → Cmd+R откроет его снова.
|
|
1098
|
+
try { localStorage.setItem('lastLocation', 'project'); } catch {}
|
|
1063
1099
|
window.appProject?.notifyState(true);
|
|
1064
1100
|
ensureClaudeMd(handle).catch(() => {});
|
|
1065
1101
|
// Подзаголовок шапки = имя открытого проекта (вместо «Видео-редактор»).
|
|
@@ -1133,7 +1169,10 @@ async function openFilm(handle) {
|
|
|
1133
1169
|
async function closeProject() {
|
|
1134
1170
|
// Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
|
|
1135
1171
|
// Cmd+R после close = welcome, а не реоткрытие.
|
|
1136
|
-
try {
|
|
1172
|
+
try {
|
|
1173
|
+
localStorage.setItem('welcomeOnNextStart', '1');
|
|
1174
|
+
localStorage.setItem('lastLocation', 'welcome');
|
|
1175
|
+
} catch {}
|
|
1137
1176
|
// Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
|
|
1138
1177
|
// filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
|
|
1139
1178
|
// мог бы попасть в новый проект (race на 600ms окно).
|
package/renderer/chat.js
CHANGED
|
@@ -847,6 +847,27 @@
|
|
|
847
847
|
// при send(). Файлы сохраняются в <currentBoard>/inbox/<name>.
|
|
848
848
|
let manualAttachments = []; // [{relPath, name, size, mime}] — файлы из drag-drop
|
|
849
849
|
|
|
850
|
+
// Короткая строка-итог контекста для отображения в чате рядом с user-msg.
|
|
851
|
+
// null если нечего показать.
|
|
852
|
+
function _ctxToInlineLabel(ctx) {
|
|
853
|
+
const parts = [];
|
|
854
|
+
if (ctx.scene) parts.push(`🎬 ${ctx.scene.name}`);
|
|
855
|
+
const byType = {};
|
|
856
|
+
for (const sel of ctx.selected) (byType[sel.type] = byType[sel.type] || []).push(sel);
|
|
857
|
+
for (const [type, items] of Object.entries(byType)) {
|
|
858
|
+
const icon = type === 'image' ? '🖼' : type === 'video' ? '🎬' : type === 'audio' ? '🎙' : type === 'text' ? '📝' : '◉';
|
|
859
|
+
if (items.length === 1) parts.push(`${icon} ${items[0].name || items[0].id?.slice(0,6)}`);
|
|
860
|
+
else {
|
|
861
|
+
const noun = (typeof _typeForms !== 'undefined' && _typeForms[type]) || ['нода','ноды','нод'];
|
|
862
|
+
parts.push(`${icon} ${items.length} ${_ru_plural(items.length, noun)}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (ctx.attachments?.length) {
|
|
866
|
+
parts.push(`📎 ${ctx.attachments.length} файл${ctx.attachments.length > 1 ? 'а' : ''}`);
|
|
867
|
+
}
|
|
868
|
+
return parts.length ? parts.join(' · ') : null;
|
|
869
|
+
}
|
|
870
|
+
|
|
850
871
|
function buildContextSnapshot() {
|
|
851
872
|
const ctx = { scene: null, selected: [], attachments: manualAttachments.slice() };
|
|
852
873
|
if (state.currentBoard) {
|
|
@@ -1268,14 +1289,26 @@
|
|
|
1268
1289
|
// Snapshot НЕ берём автоматически — теперь это явный tool take_snapshot,
|
|
1269
1290
|
// который Claude вызывает перед destructive-действиями. См. system-prompt.
|
|
1270
1291
|
// Контекст + system-prompt: формируем тут (на клиенте) и отдаём серверу.
|
|
1292
|
+
// Также сохраняем context-snapshot в user-msg чтобы юзер видел в чате
|
|
1293
|
+
// на каком контексте он спрашивал (через много turns'ов это полезно).
|
|
1271
1294
|
const ctxSnap = buildContextSnapshot();
|
|
1272
1295
|
const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
|
|
1296
|
+
// Inline context summary в visible тексте сообщения для UI render'a.
|
|
1297
|
+
// Claude получает полный context через system, юзер — через ctxLabel
|
|
1298
|
+
// в самом message (см. renderHistoryFiltered).
|
|
1299
|
+
const ctxLabel = _ctxToInlineLabel(ctxSnap);
|
|
1300
|
+
// Если есть контекст — добавляем его как первой строкой в сообщение
|
|
1301
|
+
// (с маркером [ctx:...]). Это вид'но и юзеру (renderer стилизует),
|
|
1302
|
+
// и Claude (повторяет инструкцию из system-prompt в самом turn'е).
|
|
1303
|
+
const textWithCtx = ctxLabel
|
|
1304
|
+
? `[ctx: ${ctxLabel}]\n${userText}`
|
|
1305
|
+
: userText;
|
|
1273
1306
|
appendStatus('KingKont думает…');
|
|
1274
1307
|
try {
|
|
1275
1308
|
const r = await fetch('/api/chat/send', {
|
|
1276
1309
|
method: 'POST',
|
|
1277
1310
|
headers: { 'Content-Type': 'application/json' },
|
|
1278
|
-
body: JSON.stringify({ key, text:
|
|
1311
|
+
body: JSON.stringify({ key, text: textWithCtx, system }),
|
|
1279
1312
|
});
|
|
1280
1313
|
if (!r.ok) {
|
|
1281
1314
|
const err = await r.json().catch(() => ({}));
|
|
@@ -1335,10 +1368,24 @@
|
|
|
1335
1368
|
}
|
|
1336
1369
|
div.appendChild(lbl);
|
|
1337
1370
|
if (hasContent) {
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1371
|
+
// Парсим [ctx: ...] префикс если есть — рендерим как маленький
|
|
1372
|
+
// сабтайтл над основным текстом.
|
|
1373
|
+
let mainText = m.content;
|
|
1374
|
+
let ctxLine = null;
|
|
1375
|
+
const cm = m.content.match(/^\[ctx:\s*([^\]\n]+)\]\n?([\s\S]*)$/);
|
|
1376
|
+
if (cm) { ctxLine = cm[1].trim(); mainText = cm[2].trim(); }
|
|
1377
|
+
if (ctxLine) {
|
|
1378
|
+
const cx = document.createElement('div');
|
|
1379
|
+
cx.className = 'chat-msg-ctx';
|
|
1380
|
+
cx.textContent = ctxLine;
|
|
1381
|
+
div.appendChild(cx);
|
|
1382
|
+
}
|
|
1383
|
+
if (mainText) {
|
|
1384
|
+
const body = document.createElement('div');
|
|
1385
|
+
body.className = 'chat-msg-body';
|
|
1386
|
+
body.textContent = mainText;
|
|
1387
|
+
div.appendChild(body);
|
|
1388
|
+
}
|
|
1342
1389
|
}
|
|
1343
1390
|
if (hasTools) {
|
|
1344
1391
|
// Все tools в ОДНУ строку (separated " · "), без emoji-icons —
|
package/renderer/generate.js
CHANGED
|
@@ -1725,11 +1725,15 @@ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
|
|
|
1725
1725
|
}
|
|
1726
1726
|
|
|
1727
1727
|
async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bKey, modelKey) {
|
|
1728
|
-
|
|
1728
|
+
// КЛЮЧЕВОЕ: captures projectKey НА МОМЕНТ старта — иначе если юзер
|
|
1729
|
+
// позже закроет проект, на completion мы не знаем чьё это «end».
|
|
1730
|
+
const projectKey = (state.cloudProjectId ? 'cloud:' + state.cloudProjectId
|
|
1731
|
+
: state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null);
|
|
1732
|
+
const job = { boardKey: bKey, boardHandle, kind, taskId: null, nodeId: node.id, projectKey };
|
|
1729
1733
|
state.jobs.set(node.id, job);
|
|
1730
1734
|
updateJobsBadge();
|
|
1731
1735
|
// Track в global bg-jobs (для welcome-индикаторов и system-notif при завершении).
|
|
1732
|
-
if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name });
|
|
1736
|
+
if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name, projectKey });
|
|
1733
1737
|
logJob(node.id, `gen start kind=${kind} model=${modelKey || '—'} refs=${mediaRefs?.length || 0} prompt="${(prompt||'').slice(0,200)}"`);
|
|
1734
1738
|
// Подробный дамп всех refs со всеми полями
|
|
1735
1739
|
logJob(node.id, `refs dump: ${logSafe((mediaRefs || []).map(r => ({
|
|
@@ -1856,9 +1860,10 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
|
|
|
1856
1860
|
await mutateNode(bKey, boardHandle, node.id, n => {
|
|
1857
1861
|
n.status = 'error'; n.error = e.message;
|
|
1858
1862
|
});
|
|
1863
|
+
const projectKeyOnError = state.jobs.get(node.id)?.projectKey;
|
|
1859
1864
|
state.jobs.delete(node.id);
|
|
1860
1865
|
updateJobsBadge();
|
|
1861
|
-
if (typeof bgJobEnd === 'function') bgJobEnd(node.id);
|
|
1866
|
+
if (typeof bgJobEnd === 'function') bgJobEnd(node.id, projectKeyOnError);
|
|
1862
1867
|
if (typeof showToast === 'function') showToast(`⚠ ${kind || 'gen'}: ${e.message?.slice(0, 80)}`, 'error');
|
|
1863
1868
|
if (typeof systemNotify === 'function' && document.hidden) {
|
|
1864
1869
|
systemNotify('KingKont: ошибка генерации', e.message?.slice(0, 100), { tag: 'gen-err-' + node.id }).catch(() => {});
|
|
@@ -1986,10 +1991,12 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
1986
1991
|
n.generated = { ...(n.generated || {}), creditsCharged: cost };
|
|
1987
1992
|
}
|
|
1988
1993
|
});
|
|
1994
|
+
const projectKeyAtCompletion = state.jobs.get(nodeId)?.projectKey;
|
|
1989
1995
|
state.jobs.delete(nodeId);
|
|
1990
1996
|
updateJobsBadge();
|
|
1991
|
-
// bgJob убираем (
|
|
1992
|
-
|
|
1997
|
+
// bgJob убираем — projectKey берём из job (captured at start), а не
|
|
1998
|
+
// _projectKeyForCurrent (юзер мог переключить проект, тогда было бы null).
|
|
1999
|
+
if (typeof bgJobEnd === 'function') bgJobEnd(nodeId, projectKeyAtCompletion);
|
|
1993
2000
|
if (typeof window.refreshBalance === 'function') window.refreshBalance();
|
|
1994
2001
|
// Toast — ВСЕГДА (юзер хочет видеть когда что-то сделалось).
|
|
1995
2002
|
// Стиль 'ok' для current-board, 'info' для другой доски.
|
package/renderer/state.js
CHANGED
|
@@ -83,24 +83,31 @@ function _projectKeyForCurrent() {
|
|
|
83
83
|
}
|
|
84
84
|
function bgJobStart(info) {
|
|
85
85
|
// info: {nodeId, kind, name?, projectKey?}
|
|
86
|
+
// ВАЖНО: projectKey обязателен — иначе job висит на сервере вечно
|
|
87
|
+
// (если юзер закроет проект до bgJobEnd, _projectKeyForCurrent вернёт
|
|
88
|
+
// null, и end-call станет no-op).
|
|
86
89
|
const pk = info.projectKey || _projectKeyForCurrent();
|
|
87
|
-
if (!pk) return;
|
|
90
|
+
if (!pk) { console.warn('bgJobStart: no projectKey, skipping'); return; }
|
|
88
91
|
try {
|
|
89
92
|
const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
|
|
90
|
-
if (list.some(j => j.nodeId === info.nodeId))
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
if (!list.some(j => j.nodeId === info.nodeId)) {
|
|
94
|
+
list.push({ nodeId: info.nodeId, kind: info.kind, name: info.name || null, startedAt: Date.now() });
|
|
95
|
+
localStorage.setItem(bgJobsKey(pk), JSON.stringify(list));
|
|
96
|
+
window.dispatchEvent(new CustomEvent('bgjobs:changed'));
|
|
97
|
+
}
|
|
94
98
|
} catch {}
|
|
95
|
-
// Также шлём на сервер — чтобы welcome (любой клиент) видел через WS push.
|
|
96
99
|
fetch('/api/jobs/track', {
|
|
97
100
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
98
101
|
body: JSON.stringify({ action: 'start', projectKey: pk, jobId: info.nodeId, kind: info.kind, name: info.name }),
|
|
99
102
|
}).catch(() => {});
|
|
100
103
|
}
|
|
101
104
|
function bgJobEnd(nodeId, projectKey) {
|
|
105
|
+
// projectKey ОБЯЗАТЕЛЕН — без него юзер на welcome теряет уведомление
|
|
106
|
+
// и счётчик зависает. Раньше фоллбэк на _projectKeyForCurrent давал
|
|
107
|
+
// null (welcome) → no-op. Теперь шлём end даже если localStorage не
|
|
108
|
+
// знает проект (важно для server jobsHub).
|
|
102
109
|
const pk = projectKey || _projectKeyForCurrent();
|
|
103
|
-
if (!pk) return null;
|
|
110
|
+
if (!pk) { console.warn('bgJobEnd: no projectKey for', nodeId); return null; }
|
|
104
111
|
let job = null;
|
|
105
112
|
try {
|
|
106
113
|
const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
|
|
@@ -133,8 +140,16 @@ window.bgJobEnd = bgJobEnd;
|
|
|
133
140
|
window.bgJobsAll = bgJobsAll;
|
|
134
141
|
|
|
135
142
|
// === SYSTEM NOTIFICATION (HTML5 Notification API) ===
|
|
136
|
-
// Просим разрешение
|
|
143
|
+
// Просим разрешение eagerly при старте приложения чтобы первое уведомление
|
|
144
|
+
// уже работало (без race с первой генерацией).
|
|
137
145
|
let _notifPermAsked = false;
|
|
146
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
147
|
+
// Откладываем чуть-чуть — иначе диалог появляется до того как юзер увидел UI.
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
_notifPermAsked = true;
|
|
150
|
+
Notification.requestPermission().catch(() => {});
|
|
151
|
+
}, 2000);
|
|
152
|
+
}
|
|
138
153
|
async function systemNotify(title, body, opts = {}) {
|
|
139
154
|
if (typeof Notification === 'undefined') return null;
|
|
140
155
|
let perm = Notification.permission;
|
|
@@ -142,7 +157,11 @@ async function systemNotify(title, body, opts = {}) {
|
|
|
142
157
|
_notifPermAsked = true;
|
|
143
158
|
try { perm = await Notification.requestPermission(); } catch {}
|
|
144
159
|
}
|
|
145
|
-
if (perm !== 'granted')
|
|
160
|
+
if (perm !== 'granted') {
|
|
161
|
+
// Не вышло — fallback на toast (юзер хоть так увидит).
|
|
162
|
+
if (typeof showToast === 'function') showToast('🔔 ' + title + (body ? ': ' + body : ''), 'info');
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
146
165
|
try {
|
|
147
166
|
const n = new Notification(title, {
|
|
148
167
|
body: body || '',
|
|
@@ -160,26 +179,30 @@ async function systemNotify(title, body, opts = {}) {
|
|
|
160
179
|
window.systemNotify = systemNotify;
|
|
161
180
|
|
|
162
181
|
// Глобальный toast для информативных уведомлений (генерация завершилась,
|
|
163
|
-
// resume сработал, ...). Стек справа
|
|
182
|
+
// resume сработал, ...). Стек справа сверху. Auto-dismiss:
|
|
183
|
+
// - ok/info — 10s (юзер должен заметить успех)
|
|
184
|
+
// - error — 30s (важно, не пропустить)
|
|
164
185
|
function showToast(text, kind) {
|
|
165
186
|
let host = document.getElementById('toastHost');
|
|
166
187
|
if (!host) {
|
|
167
188
|
host = document.createElement('div');
|
|
168
189
|
host.id = 'toastHost';
|
|
169
|
-
host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:
|
|
190
|
+
host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:8px; pointer-events:none; max-width:340px;';
|
|
170
191
|
document.body.appendChild(host);
|
|
171
192
|
}
|
|
172
193
|
const t = document.createElement('div');
|
|
173
194
|
const colors = {
|
|
174
|
-
ok: 'background:#
|
|
175
|
-
error: 'background:#
|
|
176
|
-
info: 'background:#
|
|
195
|
+
ok: 'background:#1f4a1f; border:1px solid #4cc44c; color:#dcffdc;',
|
|
196
|
+
error: 'background:#4a1f1f; border:1px solid #c44c4c; color:#ffdcdc;',
|
|
197
|
+
info: 'background:#1f2f4a; border:1px solid #4c7cc4; color:#dceaff;',
|
|
177
198
|
};
|
|
178
|
-
t.style.cssText = `${colors[kind] || colors.info} padding:
|
|
199
|
+
t.style.cssText = `${colors[kind] || colors.info} padding:10px 14px; border-radius:8px; font-size:13px; font-weight:500; pointer-events:auto; box-shadow:0 6px 20px rgba(0,0,0,0.5); cursor:pointer; line-height:1.4; word-break:break-word;`;
|
|
179
200
|
t.textContent = text;
|
|
201
|
+
t.title = 'Кликни чтобы закрыть';
|
|
180
202
|
t.addEventListener('click', () => t.remove());
|
|
181
203
|
host.appendChild(t);
|
|
182
|
-
|
|
204
|
+
const ttl = kind === 'error' ? 30000 : 10000;
|
|
205
|
+
setTimeout(() => t.remove(), ttl);
|
|
183
206
|
return t;
|
|
184
207
|
}
|
|
185
208
|
window.showToast = showToast;
|
package/renderer/styles.css
CHANGED
|
@@ -365,20 +365,35 @@
|
|
|
365
365
|
.chat-msg-body {
|
|
366
366
|
color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
|
|
367
367
|
}
|
|
368
|
+
/* [ctx: ...] префикс — маленький subtitle над user-msg, светло-серый. */
|
|
369
|
+
.chat-msg-ctx {
|
|
370
|
+
font-size: 10.5px; color: #889;
|
|
371
|
+
background: rgba(255,255,255,0.04); padding: 2px 6px;
|
|
372
|
+
border-radius: 4px; margin-bottom: 3px;
|
|
373
|
+
align-self: flex-start; max-width: 100%;
|
|
374
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
375
|
+
}
|
|
368
376
|
.chat-tool {
|
|
369
377
|
margin-top: 2px; background: transparent; border: none;
|
|
370
378
|
padding: 0; font-size: 10.5px; color: #666;
|
|
379
|
+
/* Гарантия что tool-блок занимает ровно одну строку (плюс развёрнутый pre)
|
|
380
|
+
— иначе при множестве tools или длинных args блок разрастался. */
|
|
381
|
+
max-width: 100%; min-width: 0;
|
|
371
382
|
}
|
|
372
383
|
.chat-tool summary {
|
|
373
|
-
cursor: pointer; color: #666; outline: none; opacity: 0.
|
|
384
|
+
cursor: pointer; color: #666; outline: none; opacity: 0.7;
|
|
374
385
|
font-family: ui-monospace, 'SF Mono', monospace;
|
|
375
386
|
list-style: none; padding: 1px 4px;
|
|
376
387
|
transition: color 0.1s, opacity 0.1s;
|
|
388
|
+
/* Один в строку, длинное → ellipsis. Click раскрывает <details>. */
|
|
389
|
+
display: block; white-space: nowrap; overflow: hidden;
|
|
390
|
+
text-overflow: ellipsis; max-width: 100%;
|
|
377
391
|
}
|
|
378
392
|
.chat-tool summary::-webkit-details-marker { display: none; }
|
|
379
393
|
.chat-tool summary::before {
|
|
380
394
|
content: '▸ '; color: #555; font-size: 9px;
|
|
381
395
|
}
|
|
396
|
+
.chat-tool[open] summary { white-space: normal; text-overflow: clip; }
|
|
382
397
|
.chat-tool[open] summary::before { content: '▾ '; }
|
|
383
398
|
.chat-tool summary:hover { color: #aaa; opacity: 1; }
|
|
384
399
|
.chat-tool pre {
|
|
@@ -478,10 +493,23 @@
|
|
|
478
493
|
}
|
|
479
494
|
.welcome-card.open-card .welcome-card-ts { color: #aac0d8; }
|
|
480
495
|
.welcome-card:hover { border-color: #4a6a9a; transform: translateY(-2px); }
|
|
496
|
+
/* Card теперь сама с фиксированным aspect-ratio + overflow:hidden →
|
|
497
|
+
обложка заполняет всю карту (вертикальная картинка кроп'ится по верху/
|
|
498
|
+
низу через object-fit:cover, не растягивает карту бесконечно вверх).
|
|
499
|
+
Meta (название + дата) — absolute поверх обложки внизу с тёмным
|
|
500
|
+
градиентом, читается на ЛЮБОЙ обложке.
|
|
501
|
+
4:3 (а не 16:9) — карты выше, лучше выглядят на стандартных и
|
|
502
|
+
вертикальных обложках, занимают меньше горизонтального места. */
|
|
503
|
+
.welcome-card {
|
|
504
|
+
aspect-ratio: 4 / 3;
|
|
505
|
+
overflow: hidden;
|
|
506
|
+
}
|
|
481
507
|
.welcome-card-thumb {
|
|
482
|
-
|
|
508
|
+
position: absolute; inset: 0;
|
|
509
|
+
background: #1a1a1a;
|
|
483
510
|
display: flex; align-items: center; justify-content: center;
|
|
484
511
|
color: #444; font-size: 32px;
|
|
512
|
+
overflow: hidden; /* страховка чтобы img/badge не выезжали */
|
|
485
513
|
}
|
|
486
514
|
.welcome-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
487
515
|
/* ☁-бейдж в углу обложки облачного проекта. Делает cloud/folder-проекты
|
|
@@ -511,9 +539,17 @@
|
|
|
511
539
|
0%, 100% { opacity: 0.85; }
|
|
512
540
|
50% { opacity: 1; }
|
|
513
541
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
.welcome-card-
|
|
542
|
+
/* Meta теперь оверлеем поверх обложки внизу с тёмным градиентом —
|
|
543
|
+
название + дата всегда читаемы независимо от цвета обложки. */
|
|
544
|
+
.welcome-card-meta {
|
|
545
|
+
position: absolute; left: 0; right: 0; bottom: 0;
|
|
546
|
+
padding: 28px 12px 8px 12px;
|
|
547
|
+
background: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 30%, rgba(0,0,0,0.92) 100%);
|
|
548
|
+
color: #fff;
|
|
549
|
+
pointer-events: none; /* клик идёт на card */
|
|
550
|
+
}
|
|
551
|
+
.welcome-card-name { font-size: 13px; color: #fff; word-break: break-all; font-weight: 500; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
|
|
552
|
+
.welcome-card-ts { font-size: 11px; color: #ccc; margin-top: 2px; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
|
|
517
553
|
.welcome-card-del {
|
|
518
554
|
position: absolute; top: 6px; right: 6px;
|
|
519
555
|
background: rgba(0,0,0,0.6); color: #aaa;
|