kingkont 0.7.3 → 0.7.5

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 CHANGED
@@ -1343,7 +1343,15 @@
1343
1343
  </div>
1344
1344
  </div>
1345
1345
  </label>
1346
- <label id="voiceRow" style="display: none;">Голос (ElevenLabs v3)
1346
+ <label id="ttsModelRow" style="display: none;">Модель TTS
1347
+ <div class="seg-control" style="flex-wrap:wrap;">
1348
+ <button class="seg active" data-tts-model="qwen/qwen3-tts" type="button" title="Qwen TTS — мульти-язык, ready-голоса">Qwen TTS</button>
1349
+ <button class="seg" data-tts-model="elevenlabs/v3" type="button" title="ElevenLabs v3 — лучший EN, тоны">ElevenLabs v3</button>
1350
+ <button class="seg" data-tts-model="minimax/speech-02-hd" type="button" title="MiniMax Speech HD — клон-голоса">MiniMax Speech HD</button>
1351
+ <button class="seg" data-tts-model="google/gemini-3.1-flash-tts-preview" type="button" title="Gemini 3.1 Flash TTS">Gemini Flash TTS</button>
1352
+ </div>
1353
+ </label>
1354
+ <label id="voiceRow" style="display: none;">Голос
1347
1355
  <select id="genVoice"></select>
1348
1356
  </label>
1349
1357
  <label id="tonesRow" style="display: none;">Тоны
@@ -1729,6 +1737,7 @@ const state = {
1729
1737
  genKind: 'image',
1730
1738
  imageModel: 'nano-banana-2', // 'nano-banana-2' | 'grok' | ...
1731
1739
  videoModel: localStorage.getItem('videoModel') || 'seedance-2', // 'seedance-2' | 'kling-o1' | 'kling-3.0' | ...
1740
+ ttsModel: localStorage.getItem('ttsModel') || 'qwen/qwen3-tts', // qwen/elevenlabs/v3/minimax/speech-02-hd/gemini
1732
1741
  videoDuration: +(localStorage.getItem('videoDuration') || 5),
1733
1742
  videoResolution: localStorage.getItem('videoResolution') || '720p',
1734
1743
  videoAspect: localStorage.getItem('videoAspect') || '9:16',
@@ -3760,6 +3769,8 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3760
3769
 
3761
3770
  $('videoModelRow').style.display = forceKind === 'video' ? '' : 'none';
3762
3771
  $('voiceRow').style.display = forceKind === 'audio' ? '' : 'none';
3772
+
3773
+ $('ttsModelRow').style.display = forceKind === 'audio' ? '' : 'none';
3763
3774
  $('tonesRow').style.display = forceKind === 'audio' ? '' : 'none';
3764
3775
  const titleEl = $('genTitle');
3765
3776
  if (titleEl) {
@@ -5013,6 +5024,8 @@ async function regenerateNode(node) {
5013
5024
  $('videoModelRow').style.display = state.genKind === 'video' ? '' : 'none';
5014
5025
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
5015
5026
 
5027
+ $('ttsModelRow').style.display = state.genKind === 'audio' ? '' : 'none';
5028
+
5016
5029
  if (g.modelKey && state.genKind === 'image') {
5017
5030
  state.imageModel = g.modelKey;
5018
5031
  document.querySelectorAll('#genModal [data-img-model]').forEach(b =>
@@ -5028,7 +5041,9 @@ async function regenerateNode(node) {
5028
5041
  syncVideoModelActive();
5029
5042
  }
5030
5043
  if (state.genKind === 'audio') {
5031
- await loadVoices();
5044
+ if (g.ttsModel) state.ttsModel = g.ttsModel;
5045
+ syncTtsModelActive();
5046
+ if (state.ttsModel === 'elevenlabs/v3') await loadVoices();
5032
5047
  if (g.voiceId) $('genVoice').value = g.voiceId;
5033
5048
  state.activeTones = (g.tones || []).slice();
5034
5049
  state.toneSuggestions = (g.tones || []).slice();
@@ -5175,6 +5190,7 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
5175
5190
 
5176
5191
  const seedGen = kind === 'audio'
5177
5192
  ? { kind, prompt: resolvedPrompt, rawPrompt, model: modelId, voiceId, voiceName,
5193
+ ttsModel: state.ttsModel || node.generated?.ttsModel || 'qwen/qwen3-tts',
5178
5194
  tones: [...state.activeTones], state: 'submitting' }
5179
5195
  : { kind, prompt: resolvedPrompt, rawPrompt, modelKey, model: modelId,
5180
5196
  refs: refs ? refs.map(r => ({ name: r.name, type: r.type, file: r.file })) : [],
@@ -5961,6 +5977,8 @@ function openPhraseFor(charInfo) {
5961
5977
 
5962
5978
  $('videoModelRow').style.display = 'none';
5963
5979
  $('voiceRow').style.display = '';
5980
+
5981
+ $('ttsModelRow').style.display = '';
5964
5982
  $('tonesRow').style.display = '';
5965
5983
  loadVoices().then(() => {
5966
5984
  if (charInfo.voice) $('genVoice').value = charInfo.voice;
@@ -6142,6 +6160,8 @@ async function openGenModal(kind) {
6142
6160
 
6143
6161
  $('videoModelRow').style.display = kind === 'video' ? '' : 'none';
6144
6162
  $('voiceRow').style.display = kind === 'audio' ? '' : 'none';
6163
+
6164
+ $('ttsModelRow').style.display = kind === 'audio' ? '' : 'none';
6145
6165
  $('tonesRow').style.display = kind === 'audio' ? '' : 'none';
6146
6166
  // Заголовок модалки = действие
6147
6167
  const title = $('genTitle');
@@ -6562,8 +6582,14 @@ document.querySelectorAll('#genModal [data-kind]').forEach(b => {
6562
6582
 
6563
6583
  $('videoModelRow').style.display = state.genKind === 'video' ? '' : 'none';
6564
6584
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
6585
+
6586
+ $('ttsModelRow').style.display = state.genKind === 'audio' ? '' : 'none';
6565
6587
  $('tonesRow').style.display = state.genKind === 'audio' ? '' : 'none';
6566
- if (state.genKind === 'audio') { loadVoices(); renderTones(); }
6588
+ if (state.genKind === 'audio') {
6589
+ syncTtsModelActive();
6590
+ if (state.ttsModel === 'elevenlabs/v3') loadVoices();
6591
+ renderTones();
6592
+ }
6567
6593
  const ph = state.genKind === 'audio'
6568
6594
  ? 'Текст, который надо озвучить...'
6569
6595
  : 'Что должно быть. Печатай @ чтобы вставить ссылку на ноду...';
@@ -6825,6 +6851,25 @@ document.querySelectorAll('#genModal [data-vid-model]').forEach(b => {
6825
6851
  localStorage.setItem('videoModel', state.videoModel);
6826
6852
  });
6827
6853
  });
6854
+ // Переключатель модели TTS
6855
+ document.querySelectorAll('#genModal [data-tts-model]').forEach(b => {
6856
+ b.addEventListener('click', () => {
6857
+ document.querySelectorAll('#genModal [data-tts-model]').forEach(x => x.classList.remove('active'));
6858
+ b.classList.add('active');
6859
+ state.ttsModel = b.dataset.ttsModel;
6860
+ localStorage.setItem('ttsModel', state.ttsModel);
6861
+ // voiceRow с ElevenLabs-голосами имеет смысл только для elevenlabs/v3.
6862
+ const showVoice = state.ttsModel === 'elevenlabs/v3';
6863
+ $('voiceRow').style.display = showVoice ? '' : 'none';
6864
+ });
6865
+ });
6866
+ function syncTtsModelActive() {
6867
+ document.querySelectorAll('#genModal [data-tts-model]').forEach(b =>
6868
+ b.classList.toggle('active', b.dataset.ttsModel === state.ttsModel));
6869
+ // Скрыть voiceRow если модель не elevenlabs (только для неё имеет смысл список).
6870
+ const showVoice = state.ttsModel === 'elevenlabs/v3';
6871
+ $('voiceRow').style.display = showVoice ? '' : 'none';
6872
+ }
6828
6873
  // Подсветить активную video-модель при открытии modal'а
6829
6874
  function syncVideoModelActive() {
6830
6875
  document.querySelectorAll('#genModal [data-vid-model]').forEach(b =>
@@ -6956,6 +7001,7 @@ $('genSubmit').addEventListener('click', async () => {
6956
7001
  kind: 'audio',
6957
7002
  prompt: finalText, rawPrompt,
6958
7003
  model: 'eleven_v3', voiceId, voiceName,
7004
+ ttsModel: state.ttsModel || 'qwen/qwen3-tts',
6959
7005
  tones: [...state.activeTones],
6960
7006
  },
6961
7007
  };
@@ -7116,11 +7162,14 @@ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
7116
7162
  n.generated = { ...(n.generated || {}), state: 'submitting' };
7117
7163
  });
7118
7164
  const provider = await plannedProvider('tts');
7119
- logJob(node.id, `→ POST /api/tts ${provider} (voice=${voiceId})`);
7165
+ // ttsModel может быть сохранён в node.generated.ttsModel (при regenerate)
7166
+ // или в текущем глобальном state.ttsModel (новая генерация).
7167
+ const ttsModel = node.generated?.ttsModel || state.ttsModel || 'qwen/qwen3-tts';
7168
+ logJob(node.id, `→ POST /api/tts → ${provider} (model=${ttsModel} voice=${voiceId || '—'})`);
7120
7169
  const r = await fetch('/api/tts', {
7121
7170
  method: 'POST',
7122
7171
  headers: { 'Content-Type': 'application/json' },
7123
- body: JSON.stringify({ text, voiceId, modelId: 'eleven_v3' }),
7172
+ body: JSON.stringify({ text, voiceId, ttsModel }),
7124
7173
  });
7125
7174
  logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
7126
7175
  if (!r.ok) {
@@ -7785,6 +7834,8 @@ async function openGenAudioForTimeline(charInfo, track, time) {
7785
7834
 
7786
7835
  $('videoModelRow').style.display = 'none';
7787
7836
  $('voiceRow').style.display = '';
7837
+
7838
+ $('ttsModelRow').style.display = '';
7788
7839
  $('tonesRow').style.display = '';
7789
7840
  $('sourceRefRow').style.display = 'none';
7790
7841
  $('charsPickRow').style.display = 'none';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.7.3",
4
- "description": "KingKont · Chatium нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
3
+ "version": "0.7.5",
4
+ "description": "KingKont \u00b7 Chatium \u2014 \u043d\u043e\u0434-\u0440\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0441\u0446\u0435\u043d \u0441 AI-\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0435\u0439 (\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438/\u0432\u0438\u0434\u0435\u043e/\u0433\u043e\u043b\u043e\u0441/SFX/\u043c\u0443\u0437\u044b\u043a\u0430/\u0442\u0435\u043a\u0441\u0442)",
5
5
  "main": "main.js",
6
6
  "bin": {
7
7
  "kingkont": "bin/kingkont.js"
@@ -16,12 +16,21 @@
16
16
  "bin/**/*",
17
17
  "scripts/**/*",
18
18
  "skill/**/*",
19
- "README.md"
19
+ "README.md",
20
+ "updates.html"
20
21
  ],
21
22
  "engines": {
22
23
  "node": ">=18"
23
24
  },
24
- "keywords": ["video-editor", "ai", "kie", "elevenlabs", "openrouter", "scene-editor", "electron"],
25
+ "keywords": [
26
+ "video-editor",
27
+ "ai",
28
+ "kie",
29
+ "elevenlabs",
30
+ "openrouter",
31
+ "scene-editor",
32
+ "electron"
33
+ ],
25
34
  "author": "Tim",
26
35
  "license": "UNLICENSED",
27
36
  "scripts": {
@@ -47,17 +56,27 @@
47
56
  "index.html",
48
57
  "settings.html",
49
58
  "assets/**/*",
50
- "package.json"
59
+ "package.json",
60
+ "updates.html"
51
61
  ],
52
62
  "extraResources": [
53
- { "from": ".env", "to": ".env" }
63
+ {
64
+ "from": ".env",
65
+ "to": ".env"
66
+ }
54
67
  ],
55
68
  "mac": {
56
69
  "category": "public.app-category.video",
57
70
  "target": "dmg",
58
71
  "icon": "assets/logo.icns"
59
72
  },
60
- "win": { "target": "nsis", "icon": "assets/logo-1024.png" },
61
- "linux": { "target": "AppImage", "icon": "assets/logo-1024.png" }
73
+ "win": {
74
+ "target": "nsis",
75
+ "icon": "assets/logo-1024.png"
76
+ },
77
+ "linux": {
78
+ "target": "AppImage",
79
+ "icon": "assets/logo-1024.png"
80
+ }
62
81
  }
63
- }
82
+ }
package/server.js CHANGED
@@ -743,19 +743,62 @@ async function handleMusic(req, res) {
743
743
  }
744
744
 
745
745
  // ---------- /api/tts (Chatium ИЛИ ElevenLabs v3) ----------
746
+ // Body клиента (всё опц. кроме text):
747
+ // {
748
+ // text,
749
+ // ttsModel?: 'qwen/qwen3-tts' | 'elevenlabs/v3' | 'minimax/speech-02-hd'
750
+ // | 'google/gemini-3.1-flash-tts-preview',
751
+ // voice?: string, // voiceId/speaker зависит от модели
752
+ // voiceId?: string, // legacy alias для voice (ElevenLabs)
753
+ // modelId?: string, // legacy: 'eleven_v3' и т.д.
754
+ // // Любые per-provider параметры пробрасываются в Chatium как есть:
755
+ // stability, similarity_boost, style, speed, language_code,
756
+ // pitch, volume, emotion, sample_rate, audio_format, language_boost,
757
+ // mode, speaker, voice_description, style_instruction, ...
758
+ // }
759
+ const TTS_PASSTHROUGH = new Set([
760
+ 'voice', 'voiceId', 'voice_id', 'speaker',
761
+ 'language', 'language_code', 'language_boost',
762
+ 'speed', 'pitch', 'volume',
763
+ 'stability', 'similarity_boost', 'style', 'style_instruction',
764
+ 'audio_format', 'sample_rate', 'bitrate', 'channel', 'emotion',
765
+ 'subtitle_enable', 'english_normalization',
766
+ 'voice_description', 'reference_audio', 'reference_text', 'mode',
767
+ 'previous_text', 'next_text',
768
+ ]);
746
769
  async function handleTts(req, res) {
747
- const { text, voiceId = 'JBFqnCBsd6RMkjVDRZzb', modelId = 'eleven_v3' } = await readJson(req);
770
+ const body = await readJson(req);
771
+ const text = body.text;
748
772
  if (!text) return send(res, 400, { error: 'нужен text' });
749
773
  const s = getSettings();
750
774
 
775
+ // Chatium-путь — поддерживает 4 модели (Qwen TTS, ElevenLabs v3,
776
+ // MiniMax Speech HD, Gemini 3.1 Flash TTS — см. spaces/api/execAudioNode.ts).
751
777
  if (s.useChatium && s.chatium?.token && s.chatium?.base) {
752
- return handleAudioViaChatium(res, s, { kind: 'tts', text, voice: voiceId, model: modelId });
778
+ const ttsBody = { kind: 'tts', text };
779
+ // Маппинг легаси `modelId` → `model` (старый клиент шлёт modelId='eleven_v3').
780
+ if (body.ttsModel) ttsBody.model = body.ttsModel;
781
+ else if (body.modelId === 'eleven_v3') ttsBody.model = 'elevenlabs/v3';
782
+ else if (body.modelId) ttsBody.model = body.modelId;
783
+ // voice: поддерживаем оба имени (voice или voiceId).
784
+ if (body.voice) ttsBody.voice = body.voice;
785
+ else if (body.voiceId) ttsBody.voice = body.voiceId;
786
+ // Per-model passthrough.
787
+ for (const k of Object.keys(body)) {
788
+ if (k === 'text' || k === 'voice' || k === 'voiceId' || k === 'ttsModel' || k === 'modelId') continue;
789
+ if (TTS_PASSTHROUGH.has(k)) ttsBody[k] = body[k];
790
+ }
791
+ return handleAudioViaChatium(res, s, ttsBody);
753
792
  }
793
+
794
+ // Прямой ElevenLabs (только eleven_v3, остальные модели только через Chatium).
754
795
  if (!s.useElevenlabs) {
755
796
  return send(res, 503, { error: 'Аудио-коннектор отключён. Включите Chatium или ElevenLabs.' });
756
797
  }
757
798
  const key = process.env.ELEVENLABS_API_KEY;
758
799
  if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
800
+ const voiceId = body.voiceId || body.voice || 'JBFqnCBsd6RMkjVDRZzb';
801
+ const modelId = body.modelId || 'eleven_v3';
759
802
  logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, `model=${modelId} text=${text.length}ch`);
760
803
  const r = await fetch(`${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, {
761
804
  method: 'POST',
package/updates.html ADDED
@@ -0,0 +1,205 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Обновления</title>
7
+ <style>
8
+ :root {
9
+ --bg: #1a1a1a;
10
+ --card: #232323;
11
+ --border: #333;
12
+ --text: #e0e0e0;
13
+ --muted: #888;
14
+ --accent: #7c3aed;
15
+ --accent-hover: #6d28d9;
16
+ --ok: #16a34a;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ html, body { height: 100%; }
20
+ body {
21
+ margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
22
+ background: var(--bg); color: var(--text); font-size: 13px;
23
+ -webkit-app-region: drag;
24
+ }
25
+ .wrap { padding: 24px 28px; max-height: 100vh; overflow: auto; -webkit-app-region: no-drag; }
26
+ h1 { margin: 0 0 4px 0; font-size: 18px; font-weight: 600; }
27
+ .sub { color: var(--muted); font-size: 12px; margin-bottom: 18px; }
28
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
29
+
30
+ .versions { display: flex; gap: 24px; margin-bottom: 14px; }
31
+ .versions .v { flex: 1; }
32
+ .versions .v label { display: block; color: var(--muted); font-size: 11px; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
33
+ .versions .v .num { font-size: 18px; font-family: ui-monospace, monospace; color: var(--text); }
34
+ .badge { display: inline-block; margin-left: 6px; padding: 2px 8px; font-size: 10px;
35
+ border-radius: 999px; vertical-align: middle; text-transform: uppercase; letter-spacing: 0.5px; }
36
+ .badge.ok { background: rgba(22,163,74,0.15); color: var(--ok); border: 1px solid rgba(22,163,74,0.4); }
37
+ .badge.new { background: rgba(124,58,237,0.18); color: #a78bfa; border: 1px solid rgba(124,58,237,0.5); }
38
+
39
+ .install-cmd {
40
+ background: #0e0e0e; border: 1px solid #2a2a2a; border-radius: 6px;
41
+ padding: 10px 14px; font-family: ui-monospace, monospace; font-size: 12px;
42
+ color: #aae06a; display: flex; align-items: center; gap: 10px; margin-top: 8px;
43
+ }
44
+ .install-cmd code { flex: 1; }
45
+ .install-cmd .copy {
46
+ padding: 4px 10px; font-size: 11px; background: #2a2a2a; color: var(--text);
47
+ border: 1px solid #3a3a3a; border-radius: 4px; cursor: pointer;
48
+ }
49
+ .install-cmd .copy:hover { background: #333; }
50
+
51
+ .changelog { font-size: 12px; color: #c4c4c4; line-height: 1.6; max-height: 240px; overflow-y: auto;
52
+ margin-top: 12px; padding: 10px 14px; background: #1c1c1c; border-radius: 6px; border: 1px solid #2a2a2a; }
53
+ .changelog .item { padding: 4px 0; }
54
+ .changelog .item .v { color: var(--muted); font-family: ui-monospace, monospace; margin-right: 6px; }
55
+
56
+ .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
57
+ button {
58
+ padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer;
59
+ border: 0; border-radius: 6px; transition: background 0.15s;
60
+ }
61
+ button.primary { background: var(--accent); color: #fff; }
62
+ button.primary:hover { background: var(--accent-hover); }
63
+ button.secondary { background: #333; color: var(--text); }
64
+ button.secondary:hover { background: #444; }
65
+ button:disabled { opacity: 0.5; cursor: wait; }
66
+
67
+ .spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #333;
68
+ border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite;
69
+ vertical-align: middle; margin-right: 6px; }
70
+ @keyframes spin { to { transform: rotate(360deg); } }
71
+
72
+ .error { color: #ef4444; font-size: 12px; margin-top: 12px; }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <div class="wrap">
77
+ <h1>Обновления KingKont</h1>
78
+ <div class="sub">Источник: <code>registry.npmjs.org/kingkont</code></div>
79
+
80
+ <div class="card">
81
+ <div class="versions">
82
+ <div class="v">
83
+ <label>Установлено</label>
84
+ <div class="num" id="vCurrent">—</div>
85
+ </div>
86
+ <div class="v">
87
+ <label>Доступно</label>
88
+ <div class="num" id="vLatest"><span class="spinner"></span></div>
89
+ </div>
90
+ </div>
91
+ <div id="updateBlock" style="display:none;">
92
+ <div style="display:flex; gap:8px; align-items:center; margin-top:6px;">
93
+ <button class="primary" id="installBtn" style="padding:8px 16px;">⬇ Установить и перезапустить</button>
94
+ <span style="color:#888; font-size:11px;">или вручную:</span>
95
+ </div>
96
+ <div class="install-cmd">
97
+ <code id="installCmd">npm i -g kingkont@latest</code>
98
+ <button class="copy" id="copyBtn">Скопировать</button>
99
+ </div>
100
+ <pre id="installLog" style="display:none; max-height:160px; overflow-y:auto; margin-top:10px;
101
+ font-family: ui-monospace, monospace; font-size: 11px; line-height: 1.4;
102
+ background: #0e0e0e; border: 1px solid #2a2a2a; border-radius: 4px;
103
+ padding: 8px 10px; color: #aaa; white-space: pre-wrap; word-break: break-all;"></pre>
104
+ <div id="installDone" style="display:none; color: var(--ok); margin-top: 10px; font-size: 12px;">
105
+ ✓ Установлено! Можно перезапустить:
106
+ <button class="primary" id="relaunchBtn" style="padding:5px 12px; font-size:12px; margin-left:6px;">Перезапустить</button>
107
+ </div>
108
+ </div>
109
+ <div id="upToDate" style="display:none; color: var(--ok); font-size: 12px; margin-top: 6px;">
110
+ ✓ У вас последняя версия.
111
+ </div>
112
+ <div class="error" id="errMsg" style="display:none;"></div>
113
+ </div>
114
+
115
+ <div class="actions">
116
+ <button class="secondary" id="recheckBtn">Проверить ещё раз</button>
117
+ <button class="primary" id="closeBtn">Закрыть</button>
118
+ </div>
119
+ </div>
120
+
121
+ <script>
122
+ const $ = (id) => document.getElementById(id);
123
+
124
+ async function check() {
125
+ $('errMsg').style.display = 'none';
126
+ $('updateBlock').style.display = 'none';
127
+ $('upToDate').style.display = 'none';
128
+ $('vLatest').innerHTML = '<span class="spinner"></span>';
129
+ $('recheckBtn').disabled = true;
130
+ try {
131
+ const r = await window.appUpdates.check();
132
+ $('vCurrent').textContent = r.current;
133
+ $('vLatest').textContent = r.latest;
134
+ if (r.isNew) {
135
+ $('vLatest').insertAdjacentHTML('beforeend', '<span class="badge new">новая версия</span>');
136
+ $('updateBlock').style.display = '';
137
+ $('installCmd').textContent = `npm i -g kingkont@${r.latest}`;
138
+ } else {
139
+ $('vLatest').insertAdjacentHTML('beforeend', '<span class="badge ok">актуально</span>');
140
+ $('upToDate').style.display = '';
141
+ }
142
+ } catch (e) {
143
+ $('vLatest').textContent = '—';
144
+ $('errMsg').style.display = '';
145
+ $('errMsg').textContent = 'Не удалось проверить: ' + (e?.message || String(e));
146
+ } finally {
147
+ $('recheckBtn').disabled = false;
148
+ }
149
+ }
150
+
151
+ $('recheckBtn').addEventListener('click', check);
152
+ $('closeBtn').addEventListener('click', () => window.appUpdates.closeWindow());
153
+ $('copyBtn').addEventListener('click', async () => {
154
+ const cmd = $('installCmd').textContent;
155
+ try {
156
+ await navigator.clipboard.writeText(cmd);
157
+ const b = $('copyBtn');
158
+ const orig = b.textContent;
159
+ b.textContent = 'Скопировано';
160
+ setTimeout(() => { b.textContent = orig; }, 1500);
161
+ } catch {}
162
+ });
163
+
164
+ let _unsubLog = null;
165
+ $('installBtn').addEventListener('click', async () => {
166
+ const installBtn = $('installBtn');
167
+ const recheckBtn = $('recheckBtn');
168
+ const log = $('installLog');
169
+ const done = $('installDone');
170
+ installBtn.disabled = true; recheckBtn.disabled = true;
171
+ installBtn.textContent = 'Устанавливаю…';
172
+ done.style.display = 'none';
173
+ log.style.display = '';
174
+ log.textContent = '';
175
+ // Подписываемся на стрим вывода npm.
176
+ _unsubLog?.();
177
+ _unsubLog = window.appUpdates.onInstallOutput(({ stream, text }) => {
178
+ log.textContent += text;
179
+ log.scrollTop = log.scrollHeight;
180
+ if (stream === 'stderr') log.style.color = '#f88';
181
+ });
182
+ // Берём latest из текущей надписи (или пытаемся 'latest').
183
+ const targetMatch = $('installCmd').textContent.match(/kingkont@(\S+)/);
184
+ const target = targetMatch ? targetMatch[1] : 'latest';
185
+ try {
186
+ await window.appUpdates.install(target);
187
+ done.style.display = '';
188
+ installBtn.textContent = '✓ Установлено';
189
+ } catch (e) {
190
+ log.style.color = '#f88';
191
+ log.textContent += `\n[error] ${e?.message || String(e)}`;
192
+ installBtn.disabled = false;
193
+ installBtn.textContent = '⬇ Попробовать снова';
194
+ } finally {
195
+ recheckBtn.disabled = false;
196
+ _unsubLog?.();
197
+ _unsubLog = null;
198
+ }
199
+ });
200
+ $('relaunchBtn').addEventListener('click', () => window.appUpdates.relaunch());
201
+
202
+ check();
203
+ </script>
204
+ </body>
205
+ </html>