kingkont 0.17.6 → 0.18.1
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 +2 -0
- package/package.json +1 -1
- package/renderer/autoNamer.js +64 -0
- package/renderer/chat.js +33 -0
- package/renderer/generate.js +61 -9
- package/renderer/notifyPanel.js +24 -1
- package/renderer/styles.css +4 -0
package/index.html
CHANGED
|
@@ -610,6 +610,8 @@
|
|
|
610
610
|
<!-- cloudProjects.js — после templates.js, переиспользует TPL_PROGRESS,
|
|
611
611
|
walkBoardFiles, collectProjectBoards, uploadProjectCoverIfAny из templates.js. -->
|
|
612
612
|
<script src="renderer/cloudProjects.js"></script>
|
|
613
|
+
<!-- autoNamer.js — LLM-based нейминг новых нод (общий util). -->
|
|
614
|
+
<script src="renderer/autoNamer.js"></script>
|
|
613
615
|
<!-- chat.js — Claude-чат для управления сценой через tools (Cmd+J). -->
|
|
614
616
|
<script src="renderer/chat.js"></script>
|
|
615
617
|
<!-- notifyPanel.js — окно событий (генерации, чат-ответы, ошибки). 🔔 в углу. -->
|
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// renderer/autoNamer.js — async LLM-based нейминг новых нод.
|
|
2
|
+
//
|
|
3
|
+
// Когда юзер генерит ноду (image/video/audio/text), вместо стандартного
|
|
4
|
+
// `slugifyPrompt(prompt).slice(0, 30)` зовём LLM придумать имя в стиле
|
|
5
|
+
// существующих нод проекта. Возвращает строку (1-3 слова на русском),
|
|
6
|
+
// fallback на slugify если LLM недоступен.
|
|
7
|
+
//
|
|
8
|
+
// Дизайн:
|
|
9
|
+
// - Вызов через /api/text (server-side LLM proxy уже есть).
|
|
10
|
+
// - Не блокируем UI: вызов async, max ~2s.
|
|
11
|
+
// - Fallback: при ошибке/timeout возвращаем slugify-результат.
|
|
12
|
+
// - Дедуп: уникальность гарантируется в caller'е (uniqueNodeName).
|
|
13
|
+
|
|
14
|
+
(function () {
|
|
15
|
+
// Quick fallback на slugifyPrompt-like логику.
|
|
16
|
+
function _slugFallback(prompt) {
|
|
17
|
+
if (!prompt) return 'Нода';
|
|
18
|
+
const s = String(prompt).trim().split(/[\s.,!?:;]+/).slice(0, 3).join(' ');
|
|
19
|
+
return s.slice(0, 50) || 'Нода';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function autoNameNode({ prompt, type, existingNames }) {
|
|
23
|
+
if (!prompt || prompt.length < 3) return _slugFallback(prompt);
|
|
24
|
+
const typeLabel = type === 'image' ? 'image' : type === 'video' ? 'video'
|
|
25
|
+
: type === 'audio' ? 'audio' : type === 'text' ? 'text' : 'node';
|
|
26
|
+
// Берём последние 30 — обычно стилистика однородная и за ~30 видна.
|
|
27
|
+
const sample = (existingNames || []).filter(Boolean).slice(-30);
|
|
28
|
+
const sys =
|
|
29
|
+
'Ты придумываешь короткое имя для node в нод-редакторе. ' +
|
|
30
|
+
'ПРАВИЛА: 1-3 слова, на русском, без кавычек, без пояснений. ' +
|
|
31
|
+
'Должно отражать СУТЬ промпта/контента. Не повторяй существующие имена. ' +
|
|
32
|
+
'Если в существующих есть стиль (например все имена — короткие глаголы или поэтичные эпитеты) — следуй ему.\n\n' +
|
|
33
|
+
`Существующие имена в проекте (${sample.length}): ${sample.length ? sample.map(n => `«${n}»`).join(', ') : '(нет)'}\n\n` +
|
|
34
|
+
`Тип ноды: ${typeLabel}\n` +
|
|
35
|
+
`Промпт/контент: ${String(prompt).slice(0, 600)}\n\n` +
|
|
36
|
+
'Ответь ОДНОЙ СТРОКОЙ — только имя, ничего больше.';
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const r = await fetch('/api/text', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
prompt: 'Придумай имя.',
|
|
44
|
+
system: sys,
|
|
45
|
+
// Используем самую быструю модель — имя короткое, надо моментально.
|
|
46
|
+
model: 'anthropic/claude-haiku-4.5',
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
if (!r.ok) return _slugFallback(prompt);
|
|
50
|
+
const d = await r.json();
|
|
51
|
+
let name = (d?.text || '').trim();
|
|
52
|
+
// Убираем кавычки/lf/trailing-точки.
|
|
53
|
+
name = name.replace(/^["«]+|["»]+$/g, '').replace(/[\r\n].*/, '').trim();
|
|
54
|
+
// Защита от пустоты или огромных строк (LLM может выдать абзац).
|
|
55
|
+
if (!name || name.length > 60) return _slugFallback(prompt);
|
|
56
|
+
return name;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn('[autoNamer] failed:', e?.message);
|
|
59
|
+
return _slugFallback(prompt);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
window.autoNameNode = autoNameNode;
|
|
64
|
+
})();
|
package/renderer/chat.js
CHANGED
|
@@ -179,6 +179,20 @@
|
|
|
179
179
|
state.currentBoard.metadata.nodes.push(node);
|
|
180
180
|
scheduleSave();
|
|
181
181
|
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
182
|
+
// Async auto-name если name не задан и есть prompt (LLM придумает
|
|
183
|
+
// имя в стиле существующих нод).
|
|
184
|
+
if (!node.name && prompt && typeof autoNameNode === 'function') {
|
|
185
|
+
const existing = state.currentBoard.metadata.nodes.map(n => n.name).filter(Boolean);
|
|
186
|
+
autoNameNode({ prompt, type, existingNames: existing }).then(autoName => {
|
|
187
|
+
if (!autoName) return;
|
|
188
|
+
const u = (typeof uniqueNodeName === 'function')
|
|
189
|
+
? uniqueNodeName(state.currentBoard.metadata.nodes, autoName)
|
|
190
|
+
: autoName;
|
|
191
|
+
node.name = u;
|
|
192
|
+
scheduleSave();
|
|
193
|
+
if (typeof refreshNodeDOM === 'function') refreshNodeDOM(node);
|
|
194
|
+
}).catch(() => {});
|
|
195
|
+
}
|
|
182
196
|
return { ok: true, id };
|
|
183
197
|
},
|
|
184
198
|
},
|
|
@@ -1297,6 +1311,24 @@
|
|
|
1297
1311
|
} else if (statusEl && !statusEl.classList.contains('error')) {
|
|
1298
1312
|
statusEl.remove();
|
|
1299
1313
|
}
|
|
1314
|
+
// Бейдж «думает» на кнопке #chatBtn — видно даже когда панель скрыта.
|
|
1315
|
+
_updateChatBtnBadge(busy);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Маленький pulse-индикатор на 💬 Чат кнопке когда server-side loop идёт.
|
|
1319
|
+
// Юзер видит «работа в фоне» даже если панель закрыта или он на другой сцене.
|
|
1320
|
+
function _updateChatBtnBadge(busy) {
|
|
1321
|
+
const btn = document.getElementById('chatBtn');
|
|
1322
|
+
if (!btn) return;
|
|
1323
|
+
let dot = btn.querySelector('.chat-busy-dot');
|
|
1324
|
+
if (busy) {
|
|
1325
|
+
if (!dot) {
|
|
1326
|
+
dot = document.createElement('span');
|
|
1327
|
+
dot.className = 'chat-busy-dot';
|
|
1328
|
+
dot.style.cssText = 'display:inline-block; width:7px; height:7px; border-radius:50%; background:#fc9; margin-left:6px; animation:bgBadgePulse 1.2s ease-in-out infinite; vertical-align:middle;';
|
|
1329
|
+
btn.appendChild(dot);
|
|
1330
|
+
}
|
|
1331
|
+
} else if (dot) dot.remove();
|
|
1300
1332
|
}
|
|
1301
1333
|
|
|
1302
1334
|
async function send(userText) {
|
|
@@ -1621,6 +1653,7 @@
|
|
|
1621
1653
|
resetInMemory: async () => {
|
|
1622
1654
|
history = []; snapshots = [];
|
|
1623
1655
|
renderHistory(); renderSnapshotBar();
|
|
1656
|
+
_updateChatBtnBadge(false); // dot прячется когда нет проекта
|
|
1624
1657
|
// Polling оставляем активным — это и есть «бг-чат».
|
|
1625
1658
|
},
|
|
1626
1659
|
// board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
|
package/renderer/generate.js
CHANGED
|
@@ -475,7 +475,13 @@ $('textGenSubmit').addEventListener('click', async () => {
|
|
|
475
475
|
type: 'text', file: `texts/${mdName}`, text: '',
|
|
476
476
|
x: spot.x, y: spot.y,
|
|
477
477
|
status: 'generating',
|
|
478
|
-
generated: {
|
|
478
|
+
generated: {
|
|
479
|
+
kind: 'text', rawPrompt, prompt: resolvedPrompt, model, state: 'submitting',
|
|
480
|
+
// refs — для resume после reload. boardHandle не сериализуется,
|
|
481
|
+
// на resume re-resolve через state.currentBoard.handle (тот же
|
|
482
|
+
// board — мы только что в нём создали ноду).
|
|
483
|
+
refs: imageRefs.map(r => ({ name: r.name, key: r.key, type: r.type, file: r.file })),
|
|
484
|
+
},
|
|
479
485
|
};
|
|
480
486
|
state.currentBoard.metadata.nodes.push(node);
|
|
481
487
|
canvas.appendChild(await createNodeEl(node));
|
|
@@ -487,30 +493,57 @@ $('textGenSubmit').addEventListener('click', async () => {
|
|
|
487
493
|
});
|
|
488
494
|
|
|
489
495
|
async function _imageRefToDataUrl(ref) {
|
|
496
|
+
// Возвращает {url, size, mime} или {error}. Раньше тихо возвращал null
|
|
497
|
+
// на любую ошибку → дебаг был невозможен (юзер видел «картинка не
|
|
498
|
+
// подаётся», но в логах ничего).
|
|
490
499
|
try {
|
|
500
|
+
if (!ref || !ref.file) return { error: 'no file in ref' };
|
|
501
|
+
if (!ref.boardHandle || typeof ref.boardHandle.getDirectoryHandle !== 'function') {
|
|
502
|
+
return { error: 'no boardHandle in ref' };
|
|
503
|
+
}
|
|
491
504
|
const fh = await resolveBoardFile(ref.boardHandle, ref.file);
|
|
492
505
|
const file = await fh.getFile();
|
|
493
|
-
|
|
506
|
+
const url = await new Promise((res, rej) => {
|
|
494
507
|
const r = new FileReader();
|
|
495
508
|
r.onload = () => res(r.result);
|
|
496
509
|
r.onerror = () => rej(r.error);
|
|
497
510
|
r.readAsDataURL(file);
|
|
498
511
|
});
|
|
499
|
-
|
|
512
|
+
return { url, size: file.size, mime: file.type || 'image/*' };
|
|
513
|
+
} catch (e) {
|
|
514
|
+
return { error: e?.message || String(e) };
|
|
515
|
+
}
|
|
500
516
|
}
|
|
501
517
|
|
|
502
518
|
async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
|
|
503
519
|
state.jobs.set(node.id, { boardKey: bKey, kind: 'text', nodeId: node.id });
|
|
504
520
|
if (typeof updateJobsBadge === 'function') updateJobsBadge();
|
|
505
|
-
logJob(node.id, `text-gen start model=${model} prompt="${(prompt||'').slice(0,
|
|
521
|
+
logJob(node.id, `text-gen start model=${model} prompt="${(prompt||'').slice(0,200)}" images=${imageRefs?.length||0}`);
|
|
522
|
+
// Дамп refs ДО загрузки — видно сразу что именно собирается прикрутить
|
|
523
|
+
// к запросу (имя, файл, доска). Если refs.length=0 при @-mention в
|
|
524
|
+
// промпте — значит gatherMediaRefs не нашёл (имя не совпало / нода
|
|
525
|
+
// другого типа). Если refs есть, но загрузка падает — следующий лог
|
|
526
|
+
// покажет error по каждому.
|
|
527
|
+
if (imageRefs?.length) {
|
|
528
|
+
logJob(node.id, `image refs (${imageRefs.length}): ${imageRefs.map((r, i) => `[${i+1}] @${r.key||r.name} file=${r.file} board=${r.boardHandle?.name||'—'}`).join(' | ')}`);
|
|
529
|
+
} else {
|
|
530
|
+
logJob(node.id, 'image refs: НЕТ — модель получит только текст. Если ожидалась картинка, проверь @-mention (имя ноды должно совпадать) и тип ноды.');
|
|
531
|
+
}
|
|
506
532
|
try {
|
|
507
533
|
const images = [];
|
|
508
|
-
for (
|
|
509
|
-
const
|
|
510
|
-
|
|
534
|
+
for (let i = 0; i < (imageRefs || []).length; i++) {
|
|
535
|
+
const ref = imageRefs[i];
|
|
536
|
+
const result = await _imageRefToDataUrl(ref);
|
|
537
|
+
if (result.url) {
|
|
538
|
+
images.push({ name: ref.name, url: result.url });
|
|
539
|
+
const sizeKb = (result.size / 1024).toFixed(1);
|
|
540
|
+
logJob(node.id, ` ✓ image [${i+1}] @${ref.key||ref.name}: ${result.mime}, ${sizeKb}KB → [image ${images.length}] в промпте`);
|
|
541
|
+
} else {
|
|
542
|
+
logJob(node.id, ` ✗ image [${i+1}] @${ref.key||ref.name} НЕ ЗАГРУЖЕНА: ${result.error || 'unknown'} (file=${ref.file})`);
|
|
543
|
+
}
|
|
511
544
|
}
|
|
512
545
|
const provider = await plannedProvider('text');
|
|
513
|
-
logJob(node.id, `→ POST /api/text → ${provider} (model=${model})`);
|
|
546
|
+
logJob(node.id, `→ POST /api/text → ${provider} (model=${model}, images.length=${images.length}, prompt.length=${(prompt||'').length}ch)`);
|
|
514
547
|
const r = await fetch('/api/text', {
|
|
515
548
|
method: 'POST',
|
|
516
549
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1646,6 +1679,20 @@ $('genSubmit').addEventListener('click', async () => {
|
|
|
1646
1679
|
state.currentBoard.metadata.nodes.push(node);
|
|
1647
1680
|
canvas.appendChild(await createNodeEl(node));
|
|
1648
1681
|
|
|
1682
|
+
// Async auto-name через LLM. НЕ блокируем — нода уже на холсте.
|
|
1683
|
+
// Когда LLM ответит — обновим node.name + DOM. Дедуп через uniqueNodeName.
|
|
1684
|
+
if (typeof autoNameNode === 'function' && !node.name) {
|
|
1685
|
+
const existing = state.currentBoard.metadata.nodes.map(n => n.name).filter(Boolean);
|
|
1686
|
+
autoNameNode({ prompt: rawPrompt || resolvedPrompt, type: kind, existingNames: existing }).then(name => {
|
|
1687
|
+
if (!name) return;
|
|
1688
|
+
const unique = uniqueNodeName(state.currentBoard.metadata.nodes, name);
|
|
1689
|
+
node.name = unique;
|
|
1690
|
+
scheduleSave();
|
|
1691
|
+
// Обновим title в node header (через refresh).
|
|
1692
|
+
if (typeof refreshNodeDOM === 'function') refreshNodeDOM(node);
|
|
1693
|
+
}).catch(() => {});
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1649
1696
|
// Если открывали через drag-line — фиксируем связь
|
|
1650
1697
|
if (state.pendingConnectionFrom) {
|
|
1651
1698
|
addConnection(state.pendingConnectionFrom, node.id);
|
|
@@ -1909,7 +1956,12 @@ async function resumeJob(node, bKey, boardHandle) {
|
|
|
1909
1956
|
await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
|
|
1910
1957
|
}
|
|
1911
1958
|
} else if (kind === 'text') {
|
|
1912
|
-
|
|
1959
|
+
// Re-attach boardHandle к refs — JSON не сохраняет JS-handle.
|
|
1960
|
+
// Все refs изначально с одной (текущей) доски (см. textGenSubmit),
|
|
1961
|
+
// так что подставляем boardHandle resume-доски.
|
|
1962
|
+
const imageRefs = (node.generated.refs || [])
|
|
1963
|
+
.filter(r => r.type === 'image' && r.file)
|
|
1964
|
+
.map(r => ({ ...r, boardHandle }));
|
|
1913
1965
|
const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
|
|
1914
1966
|
await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
|
|
1915
1967
|
} else {
|
package/renderer/notifyPanel.js
CHANGED
|
@@ -125,9 +125,32 @@
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// Public API.
|
|
128
|
+
// Дедуп: gen-completion идёт двумя путями (local pollJob → showToast,
|
|
129
|
+
// и server _startPoller → WS jobs:all). Без дедупа юзер видит ДВА
|
|
130
|
+
// одинаковых события с разницей в секунды. Ключ дедупа — kind+nodeId
|
|
131
|
+
// (если есть target.nodeId) или kind+text (fallback). Окно — 30 сек.
|
|
132
|
+
const DEDUP_WINDOW_MS = 30 * 1000;
|
|
133
|
+
function _dedupKey(kind, text, target) {
|
|
134
|
+
if (target?.nodeId && (kind === 'ok' || kind === 'error')) {
|
|
135
|
+
return `${kind}:${target.nodeId}`;
|
|
136
|
+
}
|
|
137
|
+
return null; // не дедупим chat / info-сообщения, у них нет nodeId
|
|
138
|
+
}
|
|
128
139
|
function addEvent({ kind, text, raw, target }) {
|
|
129
140
|
if (!text) return;
|
|
130
|
-
|
|
141
|
+
const k = kind || 'info';
|
|
142
|
+
const dk = _dedupKey(k, text, target);
|
|
143
|
+
if (dk) {
|
|
144
|
+
const cutoff = Date.now() - DEDUP_WINDOW_MS;
|
|
145
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
146
|
+
if (events[i].ts < cutoff) break;
|
|
147
|
+
if (events[i]._dk === dk) {
|
|
148
|
+
// Уже было — игнорируем (первый источник победил).
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
events.push({ ts: Date.now(), kind: k, text: String(text).slice(0, 300), raw, target: target || null, _dk: dk });
|
|
131
154
|
if (events.length > MAX_EVENTS) events.shift();
|
|
132
155
|
if (!panelOpen) {
|
|
133
156
|
unread++;
|
package/renderer/styles.css
CHANGED
|
@@ -190,6 +190,10 @@
|
|
|
190
190
|
overflow: hidden; /* recents скроллятся внутри своего блока */
|
|
191
191
|
}
|
|
192
192
|
body.no-project .sidebar, body.no-project .main, body.no-project .preview-panel { display: none !important; }
|
|
193
|
+
/* Preview-панель видна ТОЛЬКО когда таймлайн открыт. Закрыли таймлайн —
|
|
194
|
+
даже свернутая полоска preview прячется. Без этого юзер видит всегда
|
|
195
|
+
торчащий тёмный «остаток» справа, который мешает. */
|
|
196
|
+
body:has(.timeline-panel.hidden) .preview-panel { display: none !important; }
|
|
193
197
|
.welcome-inner {
|
|
194
198
|
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
|
195
199
|
width: 100%; max-width: none; padding: 0;
|