neuroalpha 0.6.13 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +79 -69
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
  4. package/package.json +4 -5
  5. package/public/activate.html +41 -19
  6. package/public/activate.js +81 -10
  7. package/public/app.js +471 -166
  8. package/public/index.html +9 -39
  9. package/public/styles.css +42 -3
  10. package/server/api/routes.js +595 -906
  11. package/server/auth/index.js +583 -761
  12. package/server/brokers/index.js +14 -3
  13. package/server/config.js +73 -53
  14. package/server/index.js +186 -212
  15. package/server/llm/local-engine.js +843 -0
  16. package/server/llm/providers.js +45 -34
  17. package/server/model/live.js +2 -1
  18. package/server/notify/index.js +304 -466
  19. package/server/strategist/index.js +4 -1
  20. package/deploy/docs/01-central.md +0 -96
  21. package/deploy/docs/02-cloud-instances.md +0 -130
  22. package/deploy/docs/03-desktop-build.md +0 -92
  23. package/deploy/docs/04-client-guide.md +0 -101
  24. package/deploy/docs/05-operations.md +0 -109
  25. package/deploy/docs/06-env-reference.md +0 -91
  26. package/deploy/docs/07-stress-test.md +0 -253
  27. package/deploy/docs/pdf/01-central.pdf +0 -0
  28. package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
  29. package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
  30. package/deploy/docs/pdf/04-client-guide.pdf +0 -0
  31. package/deploy/docs/pdf/05-operations.pdf +0 -0
  32. package/deploy/docs/pdf/06-env-reference.pdf +0 -0
  33. package/deploy/docs/pdf/07-stress-test.pdf +0 -0
  34. package/deploy/install.sh +0 -128
  35. package/deploy/k8s/instance.yaml +0 -147
  36. package/public/admin.html +0 -604
  37. package/public/landing.html +0 -1207
  38. package/public/legal.html +0 -134
  39. package/scripts/set-admin-password.mjs +0 -30
  40. package/server/billing/yookassa.js +0 -142
  41. package/server/instance/agent.js +0 -259
  42. package/server/instance/central.js +0 -514
  43. package/server/instance/identity.js +0 -79
  44. package/server/instance/provision.js +0 -166
  45. package/server/instance/signing.js +0 -71
  46. package/server/role.js +0 -44
package/public/app.js CHANGED
@@ -190,12 +190,51 @@ $('#chat-hide')?.addEventListener('click', () => setChatClosed(true));
190
190
  // ======================================================================
191
191
  // НАВИГАЦИЯ В ЕДИНОМ РАБОЧЕМ ОКНЕ
192
192
  // ======================================================================
193
+ function isLicensed() {
194
+ const u = state.config?.user;
195
+ const lic = state.config?.license || u?.license;
196
+ return Boolean(u?.licensed || lic?.valid);
197
+ }
198
+
193
199
  function isOwnerUser() {
200
+ if (!isLicensed()) return false;
194
201
  const u = state.config?.user;
195
202
  return Boolean(u?.isOwner || u?.role === 'admin' || u?.plan === 'owner' || u?.limits?.allowResearch);
196
203
  }
197
204
 
205
+ function syncLicenseTopBar() {
206
+ const licensed = isLicensed();
207
+ const u = state.config?.user;
208
+ const lic = state.config?.license || u?.license || {};
209
+ const errBar = $('#lic-error-bar');
210
+ const errTxt = $('#lic-error-text');
211
+ const userBtn = $('#btn-user');
212
+
213
+ if (errBar) {
214
+ errBar.hidden = licensed;
215
+ if (!licensed && errTxt) {
216
+ errTxt.textContent = lic.expired
217
+ ? 'СРОК ЛИЦЕНЗИИ ИСТЁК (Торговля и сигналы отключены)'
218
+ : 'ЛИЦЕНЗИЯ НЕ УСТАНОВЛЕНА (Торговля и сигналы отключены)';
219
+ }
220
+ }
221
+ if (userBtn) {
222
+ if (licensed) {
223
+ const dispName = lic.name || u?.name || 'Лицензиат';
224
+ userBtn.textContent = `${dispName} · ${u?.planName || lic.planName || ''}`;
225
+ userBtn.classList.remove('lic-err');
226
+ } else {
227
+ userBtn.textContent = '⚠ Получить лицензию';
228
+ userBtn.classList.add('lic-err');
229
+ }
230
+ }
231
+ if ($('#btn-broker-add')) $('#btn-broker-add').disabled = !licensed;
232
+ if ($('#btn-broker-stop-all')) $('#btn-broker-stop-all').disabled = !licensed;
233
+ if ($('#btn-refresh-forecast')) $('#btn-refresh-forecast').disabled = !licensed;
234
+ }
235
+
198
236
  function updateTabBadges() {
237
+ syncLicenseTopBar();
199
238
  const ovNav = $('#nnav-overview');
200
239
  if (ovNav) {
201
240
  const owner = isOwnerUser();
@@ -994,12 +1033,24 @@ function triggerTradingPulse(text = 'Обновление котировок и
994
1033
  }
995
1034
 
996
1035
  async function loadForecast(force, silent = false) {
1036
+ const box = $('#forecast');
1037
+ if (!isLicensed()) {
1038
+ state.forecast = null;
1039
+ if (box) {
1040
+ box.innerHTML = `<div class="live-card" style="border-color:#ef4444;padding:16px 18px">
1041
+ <div style="font-weight:700;color:#f87171;margin-bottom:6px">🔒 Живой радар прогнозов отключён: нет активной лицензии</div>
1042
+ <div class="muted small" style="margin-bottom:12px">Для расчёта сигналов Мосбиржи и сопровождения сделок перейдите в раздел «Лицензия», получите токен на сайте в личном кабинете и загрузите оплаченную лицензию.</div>
1043
+ <button type="button" class="primary" data-goto-license>Получить лицензию →</button>
1044
+ </div>`;
1045
+ }
1046
+ if ($('#fc-live-badge')) $('#fc-live-badge').textContent = '⛔ нет лицензии';
1047
+ return;
1048
+ }
997
1049
  if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
998
1050
  forecastBusy = true;
999
1051
  forecastBusyAt = Date.now();
1000
1052
  syncUiAvailability();
1001
1053
  triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
1002
- const box = $('#forecast');
1003
1054
  if (!silent && !state.forecast) {
1004
1055
  box.innerHTML = `<div class="ld-calc-banner" style="margin:4px 0">
1005
1056
  <div class="ld-calc-spin"></div>
@@ -1248,6 +1299,10 @@ async function loadForecast(force, silent = false) {
1248
1299
  const FORECAST_TICK_MS = 15000; // живое обновление каждые 15 секунд
1249
1300
  let lastForecastReqAt = 0;
1250
1301
  function refreshForecastIfStale() {
1302
+ if (!isLicensed()) {
1303
+ loadForecast(false, true);
1304
+ return;
1305
+ }
1251
1306
  if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
1252
1307
  if (!state.forecast) {
1253
1308
  lastForecastReqAt = Date.now();
@@ -1897,9 +1952,15 @@ function shortEndpointHost(urlStr = '') {
1897
1952
  }
1898
1953
  }
1899
1954
 
1900
- async function loadLlm() {
1901
- state.llm = await api('/api/llm/providers');
1955
+ async function loadLlm(refreshHw = false) {
1956
+ const [prov, loc] = await Promise.all([
1957
+ api('/api/llm/providers'),
1958
+ api(`/api/llm/local/state${refreshHw ? '?refresh=1' : ''}`).catch(() => null),
1959
+ ]);
1960
+ state.llm = prov;
1961
+ if (loc) state.localLlm = loc;
1902
1962
  renderLlm();
1963
+ renderLocalLlmBox();
1903
1964
  }
1904
1965
 
1905
1966
  async function selectActiveLlm(pid, mid) {
@@ -1994,6 +2055,7 @@ function renderLlm() {
1994
2055
  const isProvActive = active?.providerId === p.id;
1995
2056
  const usable = p.hasKey || !p.needsKey;
1996
2057
  const isCloud = p.id === 'neuroalpha_cloud' || p.source === 'cloud';
2058
+ const isLocalManaged = p.id === 'local_llama' || p.source === 'local';
1997
2059
  const keyBadge = p.needsKey
1998
2060
  ? (p.hasKey
1999
2061
  ? `<span class="tag ok" title="Ключ сохранён на сервере (${esc(p.keySource)})">✓ Ключ: ${esc(p.keySource || 'подключён')}</span>`
@@ -2014,9 +2076,9 @@ function renderLlm() {
2014
2076
  </div>
2015
2077
  <span class="grow"></span>
2016
2078
  <button type="button" data-act="test" title="Отправить тестовый запрос к этому подключению">⚡ Проверить связь</button>
2017
- <button type="button" data-act="remote" title="Запросить у сервера список всех доступных моделей">🔍 Модели на сервере</button>
2018
- <button type="button" data-act="edit" title="Изменить адрес, ключ или параметры подключения">⚙ Настроить</button>
2019
- ${isCloud ? '' : '<button type="button" data-act="del" class="icon" style="color:var(--muted)" title="Удалить подключение">🗑</button>'}
2079
+ ${isLocalManaged ? '' : '<button type="button" data-act="remote" title="Запросить у сервера список всех доступных моделей">🔍 Модели на сервере</button>'}
2080
+ ${isLocalManaged ? '' : '<button type="button" data-act="edit" title="Изменить адрес, ключ или параметры подключения">⚙ Настроить</button>'}
2081
+ ${(isCloud || isLocalManaged) ? '' : '<button type="button" data-act="del" class="icon" style="color:var(--muted)" title="Удалить подключение">🗑</button>'}
2020
2082
  </div>
2021
2083
  <div class="llm-meta">
2022
2084
  <span>Адрес: <code title="${esc(p.baseUrl)}">${esc(shortEndpointHost(p.baseUrl))}</code></span>
@@ -2585,7 +2647,7 @@ let brokerPollBusy = false;
2585
2647
  let lastBrokerPollTs = 0;
2586
2648
 
2587
2649
  async function pollActiveBrokerAccount(force = false) {
2588
- if (brokerPollBusy || document.hidden || editingBrokerId) return;
2650
+ if (!isLicensed() || brokerPollBusy || document.hidden || editingBrokerId) return;
2589
2651
  const brokersSecHidden = $('#brokers-trade-box')?.hidden;
2590
2652
  if (brokersSecHidden) return;
2591
2653
  const accs = state.brokers?.accounts || [];
@@ -2635,6 +2697,15 @@ function renderBrokers() {
2635
2697
 
2636
2698
  const tradeEl = $('#brokers-trade');
2637
2699
  if (!tradeEl) return;
2700
+ if (!isLicensed() || state.brokers?.unlicensed) {
2701
+ if (formBox) formBox.hidden = true;
2702
+ tradeEl.innerHTML = `<div class="live-card" style="border-color:#ef4444;padding:16px 18px">
2703
+ <div style="font-weight:700;color:#f87171;margin-bottom:6px">🔒 Торговля полностью заблокирована: нет активной лицензии</div>
2704
+ <div class="muted small" style="margin-bottom:12px">Без действующей лицензии подключение счетов, расчёт и отправка заявок брокеру отключены. Откройте раздел «Лицензия», перейдите на сайт в личный кабинет и загрузите лицензию по вашему токену.</div>
2705
+ <button type="button" class="primary" data-goto-license>Получить лицензию →</button>
2706
+ </div>`;
2707
+ return;
2708
+ }
2638
2709
  if (!accs.length) {
2639
2710
  tradeEl.innerHTML = 'Нажмите кнопку <b>«+ Добавить счёт»</b> выше, чтобы подключить ваш брокерский счёт (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции).';
2640
2711
  return;
@@ -3605,8 +3676,8 @@ function connectWs() {
3605
3676
  ws.onclose = (ev) => {
3606
3677
  connected = false;
3607
3678
  if (ev.code === 4401) {
3608
- setStatus('bad', 'Сессия не авторизована — войдите в аккаунт');
3609
- location.href = '/landing';
3679
+ setStatus('bad', 'Требуется активация или вход в терминал');
3680
+ location.href = '/';
3610
3681
  return;
3611
3682
  }
3612
3683
  setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)');
@@ -3765,214 +3836,452 @@ function connectWs() {
3765
3836
  stabilityUi(null);
3766
3837
  toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
3767
3838
  break;
3839
+ case 'localLlm:status': {
3840
+ const prevPhase = state.localLlm?.job?.phase;
3841
+ if (state.localLlm) {
3842
+ state.localLlm = { ...state.localLlm, ...m.status };
3843
+ renderLocalLlmBox();
3844
+ }
3845
+ if (m.status?.job?.phase === 'running' && prevPhase !== 'running') {
3846
+ loadLlm().catch(() => {});
3847
+ }
3848
+ break;
3849
+ }
3850
+ case 'license:changed': {
3851
+ api('/api/config').then((cfg) => {
3852
+ state.config = { ...(state.config || {}), ...cfg };
3853
+ updateTabBadges();
3854
+ loadBrokers().catch(() => {});
3855
+ if (isLicensed()) refreshForecastIfStale();
3856
+ else loadForecast(false, true);
3857
+ }).catch(() => {});
3858
+ break;
3859
+ }
3768
3860
  case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
3769
3861
  }
3770
3862
  };
3771
3863
  }
3772
3864
 
3773
- // ---------- окно подписания Договора-оферты в терминале ----------
3774
- let termOfferCallback = null;
3775
- const termOfferModal = $('#offer-modal');
3776
- const termChk1 = $('#term-offer-chk1');
3777
- const termChk2 = $('#term-offer-chk2');
3778
- const termOfferAccept = $('#term-offer-accept');
3865
+ // ---------- Менеджер локальной модели ИИ на этом ПК (llama.cpp + Qwen3 GGUF, с поддержкой AMD Ryzen AI Max+ 395/495) ----------
3866
+ let selectedLocalModelId = '';
3867
+ let selectedLocalBackend = '';
3779
3868
 
3780
- function syncTermOfferBtn() {
3781
- if (termOfferAccept) termOfferAccept.disabled = !(termChk1?.checked && termChk2?.checked);
3869
+ function fmtBytesGb(bytes) {
3870
+ return `${((Number(bytes) || 0) / (1024 ** 3)).toFixed(2)} ГБ`;
3782
3871
  }
3783
- termChk1?.addEventListener('change', syncTermOfferBtn);
3784
- termChk2?.addEventListener('change', syncTermOfferBtn);
3785
- $('#offer-close')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
3786
- $('#term-offer-decline')?.addEventListener('click', () => { termOfferModal.hidden = true; termOfferCallback = null; });
3787
- termOfferAccept?.addEventListener('click', async () => {
3788
- termOfferAccept.disabled = true;
3789
- try {
3790
- await post('/api/auth/accept-offer').catch(() => {});
3791
- termOfferModal.hidden = true;
3792
- if (termOfferCallback) await termOfferCallback();
3793
- } finally {
3794
- termOfferCallback = null;
3795
- syncTermOfferBtn();
3796
- }
3797
- });
3798
3872
 
3799
- function requireTermOffer(fn) {
3800
- termOfferCallback = fn;
3801
- if (termChk1) termChk1.checked = false;
3802
- if (termChk2) termChk2.checked = false;
3803
- syncTermOfferBtn();
3804
- if (termOfferModal) termOfferModal.hidden = false;
3805
- else fn();
3873
+ function fmtEta(sec) {
3874
+ if (!Number.isFinite(sec) || sec === null || sec < 0) return '';
3875
+ if (sec < 60) return `~${sec} сек`;
3876
+ const m = Math.floor(sec / 60);
3877
+ const s = sec % 60;
3878
+ return m >= 60 ? `~${Math.floor(m / 60)} ч ${m % 60} мин` : `~${m} мин ${s} сек`;
3806
3879
  }
3807
3880
 
3808
- // ---------- профиль, тарифы ЮKassa, VIP-токен и боты Telegram/MAX ----------
3809
- $('#btn-user')?.addEventListener('click', () => {
3810
- switchTab('settings');
3811
- showSettingsSec('cloud');
3812
- renderCloudPane();
3813
- });
3814
-
3815
- async function renderCloudPane() {
3816
- const box = $('#cloud-pane');
3881
+ function renderLocalLlmBox() {
3882
+ const box = $('#local-llm-box');
3817
3883
  if (!box) return;
3818
- const me = await api('/api/auth/me').catch(() => state.config || {});
3819
- state.config = { ...(state.config || {}), ...me };
3820
- const u = me.user;
3821
- const lic = u?.localLicense;
3822
- const isDefaultLocal = !me.cloudMode && u?.id === 1 && u?.email === 'owner@neuroalpha.local';
3823
- const dispName = (lic?.name && lic.name !== 'Владелец платформы' && lic.name !== 'Пользователь')
3824
- ? lic.name
3825
- : (isDefaultLocal ? 'Пользователь' : (u?.name || 'Пользователь'));
3826
- const dispEmail = (lic?.email && lic.email !== 'owner@neuroalpha.local')
3827
- ? lic.email
3828
- : (isDefaultLocal ? '' : (u?.email || ''));
3829
- const userBtn = $('#btn-user');
3830
- if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Войти';
3831
-
3832
- if (!u) {
3833
- box.innerHTML = `<div class="srow sblock"><div class="stxt"><div class="slbl">Вы не авторизованы</div><div class="hint">Войдите или создайте аккаунт на странице витрины, чтобы сохранять свои счета и подключать уведомления.</div></div><div class="row-gap"><a class="btn primary" href="/landing">Войти / Регистрация</a></div></div>`;
3884
+ const loc = state.localLlm;
3885
+ if (!loc?.hardware) {
3886
+ box.innerHTML = '';
3834
3887
  return;
3835
3888
  }
3889
+ const hw = loc.hardware;
3890
+ const job = loc.job || { phase: 'idle' };
3891
+ const isBusy = job.phase === 'engine' || job.phase === 'model' || job.phase === 'starting';
3836
3892
 
3837
- const nt = me.notify || {};
3838
- const code = u.notifyCode || '';
3839
- const tgUrl = nt.tgBotName ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
3840
- const maxUrl = nt.maxBotName ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
3841
-
3842
- let customBotsHtml = '';
3843
- let adminLinkHtml = '';
3844
- if (u.role === 'admin') {
3845
- if (me.cloudMode) {
3846
- adminLinkHtml = `<div class="srow sblock" style="border-top:1px solid var(--line);padding-top:12px">
3847
- <div class="stxt">
3848
- <div class="slbl">Консоль администратора платформы</div>
3849
- <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
3893
+ if (!selectedLocalModelId || !loc.models?.some((m) => m.id === selectedLocalModelId)) {
3894
+ selectedLocalModelId = loc.activeModelId || loc.recommendedModelId || loc.models?.[0]?.id || '';
3895
+ }
3896
+ if (!selectedLocalBackend || !(hw.availableBackends || []).includes(selectedLocalBackend)) {
3897
+ selectedLocalBackend = loc.activeBackend || hw.recommendedBackend || 'cpu';
3898
+ }
3899
+
3900
+ const selModel = (loc.models || []).find((m) => m.id === selectedLocalModelId) || loc.models?.[0];
3901
+ const hwBadge = hw.isRyzenAiMax
3902
+ ? '<span class="tag ok">⚡ AMD Ryzen AI Max+ (Unified Memory)</span>'
3903
+ : hw.isUnifiedMemory
3904
+ ? '<span class="tag ok">⚡ Unified Memory GPU</span>'
3905
+ : hw.usableVramGb >= 6
3906
+ ? `<span class="tag ok">GPU ${hw.usableVramGb} ГБ VRAM</span>`
3907
+ : '<span class="tag">CPU-режим</span>';
3908
+
3909
+ const ollamaHint = hw.ollama?.running
3910
+ ? `<div class="small muted" style="margin-top:4px">Обнаружен запущенный сервер <b>Ollama</b> (<code>localhost:11434</code>${hw.ollama.models?.length ? `, моделей: ${hw.ollama.models.length}` : ''}) — его также можно подключить шаблоном «Ollama» ниже.</div>`
3911
+ : '';
3912
+
3913
+ let bodyHtml = '';
3914
+ if (!hw.canInstall) {
3915
+ bodyHtml = `<div class="muted small" style="margin-top:8px;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:var(--bg2)">
3916
+ ⚠ <b>Локальная модель недоступна на этом устройстве:</b> ${esc(hw.reasonBlocked || 'недостаточно памяти или места на диске')}<br>
3917
+ Рекомендуемая конфигурация для локального ИИ-Стратега максимального уровня — рабочая станция на базе <b>AMD Ryzen AI Max+ 395 / 495</b> (64–128 ГБ единой памяти).
3918
+ </div>`;
3919
+ } else {
3920
+ const progressHtml = isBusy
3921
+ ? `<div style="margin-top:12px;padding:10px 12px;border:1px solid var(--accent);border-radius:8px;background:var(--bg2)">
3922
+ <div style="display:flex;justify-content:space-between;gap:8px;font-size:13px;margin-bottom:6px">
3923
+ <b>${esc(job.message || 'Установка…')}</b>
3924
+ <span>${job.percent || 0}%${job.speedMbSec ? ` · ${job.speedMbSec} МБ/с` : ''}${job.etaSec ? ` · осталось ${fmtEta(job.etaSec)}` : ''}</span>
3925
+ </div>
3926
+ <div style="height:8px;border-radius:999px;background:var(--line);overflow:hidden">
3927
+ <div style="height:100%;width:${Math.max(2, job.percent || 0)}%;background:var(--accent);transition:width .25s"></div>
3928
+ </div>
3929
+ ${job.totalBytes > 0 ? `<div class="muted small" style="margin-top:5px">${fmtBytesGb(job.downloadedBytes)} из ${fmtBytesGb(job.totalBytes)} · при прерывании закачка продолжится с того же места</div>` : ''}
3930
+ </div>`
3931
+ : (job.error ? `<div class="small neg" style="margin-top:8px">⚠ ${esc(job.error)}</div>` : '');
3932
+
3933
+ bodyHtml = `
3934
+ <div style="display:grid;gap:10px;margin-top:10px">
3935
+ <div class="row-gap" style="flex-wrap:wrap">
3936
+ <select id="local-llm-model-sel" style="flex:1;min-width:260px" ${isBusy ? 'disabled' : ''}>
3937
+ ${(loc.models || []).map((m) => {
3938
+ const rec = m.id === loc.recommendedModelId ? ' ★ рекомендуется под ваш ПК' : '';
3939
+ const inst = m.installed ? ' [скачана ✓]' : ` [~${m.sizeGb} ГБ]`;
3940
+ const incomp = !m.compatible ? ' — мало памяти/диска' : '';
3941
+ return `<option value="${esc(m.id)}" ${m.id === selectedLocalModelId ? 'selected' : ''} ${!m.compatible && !m.installed ? 'disabled' : ''}>${esc(m.label)}${inst}${rec}${incomp}</option>`;
3942
+ }).join('')}
3943
+ </select>
3944
+ <select id="local-llm-backend-sel" style="width:170px" title="Вычислительный движок llama-server" ${isBusy ? 'disabled' : ''}>
3945
+ ${(hw.availableBackends || ['cpu']).map((b) => `<option value="${esc(b)}" ${b === selectedLocalBackend ? 'selected' : ''}>Движок: ${esc(b.toUpperCase())}</option>`).join('')}
3946
+ </select>
3850
3947
  </div>
3948
+ ${selModel ? `<div class="muted small">${esc(selModel.desc)} · Режим ускорения: <b>${esc(selModel.accelMode)}</b></div>` : ''}
3851
3949
  <div class="row-gap" style="flex-wrap:wrap">
3852
- <a class="btn primary" href="/admin">Открыть админ-консоль (/admin) →</a>
3950
+ ${isBusy
3951
+ ? `<button type="button" id="btn-local-llm-cancel">⏸ Приостановить загрузку</button>`
3952
+ : (loc.running && loc.activeModelId === selectedLocalModelId
3953
+ ? `<span class="tag ok">● Запущена на 127.0.0.1:${loc.port}</span>
3954
+ <button type="button" id="btn-local-llm-stop">⏹ Остановить локальный движок</button>`
3955
+ : (selModel?.installed
3956
+ ? `<button type="button" class="primary" id="btn-local-llm-start">▶ Запустить и выбрать для Стратега</button>`
3957
+ : `<button type="button" class="primary" id="btn-local-llm-install" ${!selModel?.compatible ? 'disabled' : ''}>⬇ Скачать (~${selModel?.sizeGb || ''} ГБ) и включить для Стратега</button>`))}
3958
+ ${(!isBusy && selModel?.installed) ? `<button type="button" id="btn-local-llm-del" style="color:var(--red)">🗑 Удалить файл весов с диска</button>` : ''}
3959
+ <button type="button" id="btn-local-llm-rescan" class="small" ${isBusy ? 'disabled' : ''}>↻ Перепроверить железо</button>
3853
3960
  </div>
3961
+ ${progressHtml}
3854
3962
  </div>`;
3855
- } else {
3856
- customBotsHtml = `<details style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
3857
- <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Собственный бот Telegram / MAX и консоль администратора (/admin)</summary>
3858
- <form id="adm-notify-form" style="display:grid;gap:10px;margin-top:10px">
3859
- <div class="muted small">Если вы хотите отправлять уведомления через собственного бота (от <code>@BotFather</code>), укажите его имя и токен:</div>
3860
- <div class="row-gap" style="flex-wrap:wrap">
3861
- <input name="tgBotName" value="${esc(nt.tgBotName || '')}" placeholder="Имя бота Telegram (без @)" style="width:200px">
3862
- <input name="tgToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" placeholder="${nt.hasTgToken ? 'Токен Telegram задан (оставьте пустым)' : 'Токен Telegram Bot API'}" style="flex:1;min-width:220px">
3863
- </div>
3864
- <div class="row-gap" style="flex-wrap:wrap">
3865
- <input name="maxBotName" value="${esc(nt.maxBotName || '')}" placeholder="Имя бота MAX" style="width:200px">
3866
- <input name="maxToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" placeholder="${nt.hasMaxToken ? 'Токен MAX задан (оставьте пустым)' : 'Токен MAX Bot API'}" style="flex:1;min-width:220px">
3867
- <button type="submit" class="primary">Сохранить ботов</button>
3868
- </div>
3869
- <div class="muted small" style="margin-top:4px">Администрирование PaaS-портала (ЮKassa, пользователи, выпуск VIP-токенов, промокоды, OAuth): <a href="/admin" style="color:var(--accent)">Открыть /admin →</a></div>
3870
- </form>
3871
- </details>`;
3872
- }
3873
3963
  }
3874
3964
 
3875
- const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
3876
- ? 'по согласованию / без ограничений'
3877
- : u.limits?.maxCapitalPerAccountRub > 0
3878
- ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
3879
- : 'подписка не активна';
3880
- const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
3965
+ box.innerHTML = `<div class="llm-prov-card" style="border-color:rgba(59,130,246,0.45)">
3966
+ <div class="llm-head">
3967
+ <div class="llm-title">
3968
+ <span>🖥 Автономная локальная модель для Стратега (на этом ПК)</span>
3969
+ ${hwBadge}
3970
+ </div>
3971
+ </div>
3972
+ <div class="llm-meta" style="margin-top:4px">
3973
+ Процессор: <b>${esc(hw.cpuModel)}</b> (${hw.cpuCores} потоков) · ОЗУ: <b>${hw.totalRamGb} ГБ</b>${hw.gpuName ? ` · Графика: <b>${esc(hw.gpuName)}</b> (доступно ИИ: ${hw.usableVramGb} ГБ)` : ''} · Свободно на диске: <b>${hw.freeDiskGb} ГБ</b>
3974
+ </div>
3975
+ ${!hw.isRyzenAiMax ? '<div class="muted small" style="margin-top:3px">Для максимальных локальных моделей 30B–80B мы рекомендуем мини-станции на базе <b>AMD Ryzen AI Max+ 395 / 495</b> (единая память 64–128 ГБ).</div>' : ''}
3976
+ ${ollamaHint}
3977
+ ${bodyHtml}
3978
+ </div>`;
3979
+
3980
+ box.querySelector('#local-llm-model-sel')?.addEventListener('change', (e) => {
3981
+ selectedLocalModelId = e.target.value;
3982
+ renderLocalLlmBox();
3983
+ });
3984
+ box.querySelector('#local-llm-backend-sel')?.addEventListener('change', (e) => {
3985
+ selectedLocalBackend = e.target.value;
3986
+ });
3987
+ box.querySelector('#btn-local-llm-install')?.addEventListener('click', async () => {
3988
+ try {
3989
+ const st = await post('/api/llm/local/install', { modelId: selectedLocalModelId, backend: selectedLocalBackend });
3990
+ state.localLlm = { ...state.localLlm, ...st };
3991
+ renderLocalLlmBox();
3992
+ } catch (e) { toast(e.message, 'error'); }
3993
+ });
3994
+ box.querySelector('#btn-local-llm-start')?.addEventListener('click', async () => {
3995
+ try {
3996
+ toast('Запускаем локальный движок llama-server и загружаем модель в память…');
3997
+ await post('/api/llm/local/start', { modelId: selectedLocalModelId, backend: selectedLocalBackend });
3998
+ await loadLlm();
3999
+ showLlmStatus();
4000
+ toast('✓ Локальная модель запущена и назначена активной для Стратега!');
4001
+ } catch (e) { toast(e.message, 'error'); }
4002
+ });
4003
+ box.querySelector('#btn-local-llm-stop')?.addEventListener('click', async () => {
4004
+ try {
4005
+ await post('/api/llm/local/stop');
4006
+ await loadLlm();
4007
+ showLlmStatus();
4008
+ toast('Локальный движок ИИ остановлен');
4009
+ } catch (e) { toast(e.message, 'error'); }
4010
+ });
4011
+ box.querySelector('#btn-local-llm-cancel')?.addEventListener('click', async () => {
4012
+ try {
4013
+ const st = await post('/api/llm/local/cancel');
4014
+ state.localLlm = { ...state.localLlm, ...st };
4015
+ renderLocalLlmBox();
4016
+ } catch (e) { toast(e.message, 'error'); }
4017
+ });
4018
+ box.querySelector('#btn-local-llm-del')?.addEventListener('click', async () => {
4019
+ if (!confirm('Удалить скачанный .gguf файл этой модели с диска?')) return;
4020
+ try {
4021
+ await del(`/api/llm/local/model/${encodeURIComponent(selectedLocalModelId)}`);
4022
+ await loadLlm(true);
4023
+ toast('Файл модели удалён с диска');
4024
+ } catch (e) { toast(e.message, 'error'); }
4025
+ });
4026
+ box.querySelector('#btn-local-llm-rescan')?.addEventListener('click', () => loadLlm(true));
4027
+ }
4028
+
4029
+ // ---------- Автономная лицензия терминала, защита VPS и шлюз уведомлений Telegram / MAX ----------
4030
+ function openLicenseSection() {
4031
+ showMainSec('cloud');
4032
+ renderCloudPane().then(() => {
4033
+ $('#lic-token-in')?.focus();
4034
+ }).catch(() => {});
4035
+ }
4036
+
4037
+ $('#btn-user')?.addEventListener('click', openLicenseSection);
4038
+ $('#btn-get-license')?.addEventListener('click', openLicenseSection);
4039
+ document.addEventListener('click', (e) => {
4040
+ if (e.target.closest('[data-goto-license]')) openLicenseSection();
4041
+ });
4042
+
4043
+ async function renderCloudPane() {
4044
+ const box = $('#cloud-pane');
4045
+ if (!box) return;
4046
+ const me = await api('/api/auth/me').catch(() => state.config || {});
4047
+ state.config = { ...(state.config || {}), ...me };
4048
+ const u = me.user;
4049
+ const lic = me.license || u?.license || {};
4050
+ const nt = me.notify || {};
3881
4051
  updateTabBadges();
3882
4052
 
4053
+ const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
4054
+ const slots = Array.isArray(lic.slots) ? lic.slots : [];
4055
+
4056
+ const statusHtml = lic.valid
4057
+ ? `<span class="tag ok">● Активна: ${esc(u?.planName || lic.planName)}</span>`
4058
+ : `<span class="tag no">⛔ ${lic.expired ? 'Срок действия истёк' : 'Лицензия не установлена'} — торговля отключена</span>`;
4059
+
4060
+ const validStr = lic.valid
4061
+ ? (lic.perpetual
4062
+ ? 'действует <b>бессрочно</b> (100% автономно без запросов к сайту)'
4063
+ : `действует автономно до <b>${fmtDate(lic.validUntil)}</b>`)
4064
+ : 'привяжите токен из личного кабинета и загрузите оплаченные лицензии ниже';
4065
+
4066
+ const capLimStr = u?.limits?.maxCapitalPerAccountRub >= 100000000
4067
+ ? 'без ограничений'
4068
+ : u?.limits?.maxCapitalPerAccountRub > 0
4069
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ вся прибыль)`
4070
+ : '—';
4071
+ const dirLimStr = lic.valid
4072
+ ? (u?.limits?.allowShorts ? 'покупки и шорты (Long / Short)' : 'только покупки (Long)')
4073
+ : 'торговля отключена';
4074
+
4075
+ const slotsTableHtml = slots.length
4076
+ ? `<div style="margin-top:10px;overflow-x:auto">
4077
+ <table style="width:100%;font-size:13px">
4078
+ <thead><tr><th>Лицензия</th><th>Тариф</th><th>Статус и срок (1 мес. = 31 сутки)</th><th>Устройство</th></tr></thead>
4079
+ <tbody>${slots.map((s) => {
4080
+ const isThisPc = s.id === lic.licId;
4081
+ const stBadge = s.revoked
4082
+ ? '<span class="tag no">отозвана</span>'
4083
+ : s.perpetual
4084
+ ? '<span class="tag ok">бессрочно</span>'
4085
+ : s.activated
4086
+ ? `<span class="tag ok">активна до ${fmtDate(s.validUntil)}</span>`
4087
+ : `<span class="tag mid">в очереди (${s.totalRemainingDays || 0} сут.)</span>`;
4088
+ const grantsStr = (s.grants || []).filter((g) => g.status !== 'expired').map((g) => `${esc(g.planName)} (${g.durationDays === 0 ? '∞' : `${g.remainingDays ?? g.durationDays} сут.`})`).join(' → ');
4089
+ return `<tr${isThisPc ? ' style="background:var(--accent-bg)"' : ''}>
4090
+ <td><b>${esc(s.label || s.id)}</b> ${isThisPc ? '<span class="tag ok">этот терминал</span>' : ''}</td>
4091
+ <td><b>${esc(s.planName)}</b></td>
4092
+ <td>${stBadge}${grantsStr ? `<div class="muted small">${grantsStr}</div>` : ''}</td>
4093
+ <td class="small">${s.bound ? `💻 ${esc(s.hostname || 'ПК')}` : '<span class="muted">свободна</span>'}</td>
4094
+ </tr>`;
4095
+ }).join('')}</tbody>
4096
+ </table>
4097
+ </div>`
4098
+ : '<div class="muted small" style="margin-top:6px">Загруженных лицензий пока нет.</div>';
4099
+
3883
4100
  box.innerHTML = `
3884
- <div class="srow sblock">
4101
+ <div class="srow sblock" style="${!lic.valid ? 'border:1px solid #ef4444;border-radius:10px;padding:14px 16px;background:rgba(211,47,47,0.08)' : ''}">
3885
4102
  <div class="stxt">
3886
- <div class="slbl">${esc(dispName)}${dispEmail ? ` · <span class="muted">${esc(dispEmail)}</span>` : ''} <span class="tag ok" style="margin-left:6px">${esc(u.planName)}</span></div>
4103
+ <div class="slbl">Состояние лицензии терминала: ${statusHtml}</div>
3887
4104
  <div class="hint">
3888
- Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
4105
+ Срок: ${validStr}${lic.email ? ` · Аккаунт: <b>${esc(lic.email)}</b>` : ''}<br>
4106
+ ${lic.valid ? `Квота счетов: до <b>${u?.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим: <b>${dirLimStr}</b>.` : 'Без действующей лицензии любые торговые операции и живой радар сигналов заблокированы.'}
3889
4107
  </div>
3890
4108
  </div>
3891
4109
  <div class="row-gap" style="flex-wrap:wrap">
3892
- ${me.cloudMode ? `<button type="button" id="btn-cloud-verify">Проверить оплату</button>` : ''}
3893
- ${(me.cloudMode || me.role === 'instance' || !isDefaultLocal) ? `<button type="button" id="btn-cloud-logout">Выйти</button>` : ''}
4110
+ ${lic.hasCabinetToken ? '<button type="button" class="primary" id="btn-lic-refresh">↻ Обновить лицензии с сайта</button>' : ''}
4111
+ ${lic.hasCabinetToken ? '<button type="button" id="btn-lic-detach" style="color:var(--red)">Отключить токен</button>' : ''}
4112
+ ${!me.isLoopback ? '<button type="button" id="btn-term-logout" style="color:var(--red)">Выйти из сессии</button>' : ''}
3894
4113
  </div>
3895
4114
  </div>
4115
+
3896
4116
  <div class="srow sblock">
3897
4117
  <div class="stxt">
3898
- <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
3899
- <div class="hint">
3900
- • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
3901
- • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
3902
- • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
3903
- </div>
4118
+ <div class="slbl">Шаг 1. Перейдите на сайт портала и получите токен личного кабинета</div>
4119
+ <div class="hint">Войдите в профиль на сайте по <b>Email + одноразовому коду</b>, оформите нужную лицензию и скопируйте ваш персональный токен <code>na_tok_…</code>.</div>
3904
4120
  </div>
3905
4121
  <div class="row-gap" style="flex-wrap:wrap">
3906
- <input id="promo-code-in" placeholder="Промокод или VIP-токен (VIP-…)" style="width:240px">
3907
- <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
3908
- <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
3909
- <button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
3910
- <a class="btn" href="/landing#pricing">Согласовать VIP →</a>
4122
+ <input id="lic-portal-url" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" style="width:250px" title="Адрес портала лицензий">
4123
+ <a id="btn-open-portal" class="btn primary" href="${esc(portalUrl)}" target="_blank" rel="noopener">🌐 Открыть сайт / Личный кабинет →</a>
4124
+ </div>
4125
+ </div>
4126
+
4127
+ <div class="srow sblock">
4128
+ <div class="stxt">
4129
+ <div class="slbl">Шаг 2. Привяжите токен и загрузите оплаченные лицензии</div>
4130
+ <div class="hint">Вставьте скопированный из личного кабинета токен <code>na_tok_…</code> — терминал скачает подписанные криптоключом лицензии и включит работу автономно на весь оплаченный срок.</div>
4131
+ <div id="lic-slot-choice" hidden style="margin-top:10px;display:grid;gap:6px"></div>
3911
4132
  </div>
4133
+ <form id="form-lic-bind" class="row-gap" style="flex-wrap:wrap">
4134
+ <input id="lic-token-in" name="token" placeholder="na_tok_…" style="width:300px;font-family:monospace" required spellcheck="false">
4135
+ <button type="submit" class="primary" id="btn-lic-download">⬇ Загрузить лицензии</button>
4136
+ </form>
3912
4137
  </div>
4138
+
4139
+ <div class="srow sblock" style="display:block">
4140
+ <div class="slbl">Загруженные лицензии и очередь пакетов</div>
4141
+ ${slotsTableHtml}
4142
+ </div>
4143
+
4144
+ <div class="srow sblock">
4145
+ <div class="stxt">
4146
+ <div class="slbl">Защита удалённого доступа (для работы на арендованной виртуалке / VPS)</div>
4147
+ <div class="hint">
4148
+ На локальном ПК (<code>localhost</code>) интерфейс открыт сразу. При обращении по внешнему IP сервер требует ваш токен <code>na_tok_…</code> либо дополнительный локальный пароль (${lic.hasLocalPassword ? '<b class="pos">установлен ✓</b>' : 'не задан'}).
4149
+ </div>
4150
+ </div>
4151
+ <form id="form-local-pass" class="row-gap" style="flex-wrap:wrap">
4152
+ <input name="password" type="password" autocomplete="new-password" placeholder="${lic.hasLocalPassword ? 'Новый пароль (пусто — сбросить)' : 'Задать пароль (от 6 симв.)'}" style="width:240px">
4153
+ <button type="submit">Сохранить пароль VPS</button>
4154
+ </form>
4155
+ </div>
4156
+
3913
4157
  <div class="srow sblock">
3914
4158
  <div class="stxt">
3915
4159
  <div class="slbl">Уведомления и общение со Стратегом в Telegram и MAX</div>
3916
4160
  <div class="hint">
3917
- Ваш код привязки: <b><code>${esc(code)}</code></b> — отправьте этот код боту в Telegram или в мессенджер MAX.<br>
3918
- После привязки туда приходят сигналы и кнопки подтверждения заявок, а также вы можете писать боту любые вопросы ИИ-Стратегу (или команды <code>/forecast</code>, <code>/portfolio</code>, <code>/today</code>, <code>/news</code>).<br>
3919
- Статус: Telegram — ${u.tgConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'} · MAX — ${u.maxConnected ? '<b class="pos">подключён ✓</b>' : '<span class="muted">не привязан</span>'}
4161
+ По умолчанию уведомления и кнопки подтверждения заявок работают через ботов портала (привяжите Telegram или MAX в личном кабинете на сайте). При желании ниже можно подключить собственного бота напрямую.
3920
4162
  </div>
3921
4163
  </div>
3922
4164
  <div class="row-gap" style="flex-wrap:wrap">
3923
- ${tgUrl ? `<a class="btn" href="${esc(tgUrl)}" target="_blank" rel="noopener">Открыть Telegram</a>` : ''}
3924
- ${maxUrl ? `<a class="btn" href="${esc(maxUrl)}" target="_blank" rel="noopener">Открыть MAX</a>` : ''}
3925
- <button type="button" id="btn-notify-test">Тест уведомления</button>
3926
- <a class="btn" href="/legal" target="_blank">Договор-оферта →</a>
4165
+ <button type="button" id="btn-notify-test">✉ Отправить тест уведомления</button>
3927
4166
  </div>
3928
4167
  </div>
3929
- ${adminLinkHtml}
3930
- ${customBotsHtml}
4168
+
4169
+ <details style="margin-top:8px;padding-top:10px;border-top:1px solid var(--line)">
4170
+ <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Прямое подключение собственного личного бота Telegram / MAX (автономно без портала)</summary>
4171
+ <form id="direct-notify-form" style="display:grid;gap:10px;margin-top:10px">
4172
+ <label class="row-gap small"><input type="checkbox" name="usePortalRelay" ${nt.usePortalRelay !== false ? 'checked' : ''}> Разрешить отправку через ботов портала, если прямой бот не заполнен</label>
4173
+ <div class="row-gap" style="flex-wrap:wrap">
4174
+ <input name="tgToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectTg ? 'Токен личного Telegram-бота задан' : 'Токен Telegram Bot API (@BotFather)'}" style="flex:1;min-width:240px">
4175
+ <input name="tgChatId" value="${esc(nt.tgChatId || '')}" placeholder="Telegram Chat ID (заполнится при /start)" style="width:240px">
4176
+ </div>
4177
+ <div class="row-gap" style="flex-wrap:wrap">
4178
+ <input name="maxToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectMax ? 'Токен личного MAX-бота задан' : 'Токен MAX Bot API'}" style="flex:1;min-width:240px">
4179
+ <input name="maxChatId" value="${esc(nt.maxChatId || '')}" placeholder="MAX Chat ID" style="width:240px">
4180
+ <button type="submit" class="primary">Сохранить прямого бота</button>
4181
+ </div>
4182
+ </form>
4183
+ </details>
3931
4184
  `;
3932
4185
 
3933
- box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
3934
- const r = await post('/api/auth/logout').catch(() => ({}));
3935
- location.href = r?.redirect || '/landing';
4186
+ const portalInput = box.querySelector('#lic-portal-url');
4187
+ const openPortalLink = box.querySelector('#btn-open-portal');
4188
+ portalInput?.addEventListener('input', () => {
4189
+ const v = portalInput.value.trim() || 'https://neuroalpha.odant.org';
4190
+ if (openPortalLink && /^https?:\/\//i.test(v)) openPortalLink.href = v;
3936
4191
  });
3937
- box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
4192
+
4193
+ const applyActivatedState = async () => {
4194
+ state.config = await api('/api/config');
4195
+ updateTabBadges();
4196
+ await renderCloudPane();
4197
+ await loadBrokers().catch(() => {});
4198
+ await loadLlm().catch(() => {});
4199
+ if (isOwnerUser()) await loadRuns().catch(() => {});
4200
+ if (isLicensed()) refreshForecastIfStale();
4201
+ else loadForecast(false, true);
4202
+ };
4203
+
4204
+ box.querySelector('#btn-lic-refresh')?.addEventListener('click', async () => {
3938
4205
  try {
3939
- const r = await post('/api/billing/verify');
3940
- toast(r.status === 'succeeded' ? `Оплата подтверждена! Тариф: ${r.user?.planName}` : `Статус платежа: ${r.status}`);
3941
- renderCloudPane();
4206
+ const pUrl = portalInput?.value.trim() || portalUrl;
4207
+ const r = await post('/api/local/refresh-license', { portalUrl: pUrl });
4208
+ toast(`✓ Лицензии обновлены: ${r.state?.planName || 'активна'}`);
4209
+ await applyActivatedState();
3942
4210
  } catch (e) { toast(e.message, 'error'); }
3943
4211
  });
3944
- box.querySelectorAll('[data-checkout]').forEach((b) => b.addEventListener('click', () => {
3945
- const plan = b.dataset.checkout;
3946
- requireTermOffer(async () => {
3947
- try {
3948
- const r = await post('/api/billing/checkout', { plan, months: 1, returnOrigin: location.origin });
3949
- if (r.confirmationUrl) location.href = r.confirmationUrl;
3950
- } catch (e) { toast(e.message, 'error'); }
3951
- });
3952
- }));
3953
- box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
4212
+
4213
+ box.querySelector('#btn-lic-detach')?.addEventListener('click', async () => {
4214
+ if (!confirm('Отключить токен и удалить локальный сертификат лицензии с этого компьютера? Торговля сразу остановится.')) return;
3954
4215
  try {
3955
- await post('/api/auth/notify-test');
3956
- toast('Тестовое уведомление отправлено в привязанные мессенджеры');
4216
+ await post('/api/local/detach-license', {});
4217
+ toast('Лицензия отключена — торговля остановлена');
4218
+ await applyActivatedState();
3957
4219
  } catch (e) { toast(e.message, 'error'); }
3958
4220
  });
3959
- box.querySelector('#btn-promo-redeem')?.addEventListener('click', () => {
3960
- const code = box.querySelector('#promo-code-in')?.value.trim();
3961
- if (!code) return toast('Введите промокод или персональный VIP-токен', 'error');
3962
- requireTermOffer(async () => {
3963
- try {
3964
- const r = await post('/api/auth/redeem-promo', { code });
3965
- toast(`Код активирован! Ваш тариф: ${r.user?.planName}`);
3966
- renderCloudPane();
3967
- } catch (e) { toast(e.message, 'error'); }
3968
- });
4221
+
4222
+ box.querySelector('#form-lic-bind')?.addEventListener('submit', async (e) => {
4223
+ e.preventDefault();
4224
+ const f = e.target.elements;
4225
+ const pUrl = portalInput?.value.trim() || portalUrl;
4226
+ const pickedSlot = box.querySelector('input[name="pick_slot_id"]:checked')?.value || '';
4227
+ try {
4228
+ const r = await post('/api/local/activate', {
4229
+ token: f.token.value.trim(),
4230
+ portalUrl: pUrl,
4231
+ slotId: pickedSlot,
4232
+ });
4233
+ if (r.chooseSlot && Array.isArray(r.slots) && r.slots.length) {
4234
+ const slotBox = box.querySelector('#lic-slot-choice');
4235
+ slotBox.hidden = false;
4236
+ slotBox.innerHTML = `<div class="small" style="font-weight:600">Выберите свободную лицензию для этого терминала и нажмите «Загрузить лицензии» ещё раз:</div>`
4237
+ + r.slots.map((s, i) => `<label class="row-gap small" style="padding:6px 10px;border:1px solid var(--line);border-radius:7px;cursor:pointer">
4238
+ <input type="radio" name="pick_slot_id" value="${esc(s.id)}" ${i === 0 ? 'checked' : ''}>
4239
+ <span><b>${esc(s.label || s.id)}</b> · ${esc(s.planName)} (${s.perpetual ? 'бессрочно' : `${s.totalRemainingDays || 0} сут.`})</span>
4240
+ </label>`).join('');
4241
+ toast('Выберите нужный слот лицензии в списке');
4242
+ return;
4243
+ }
4244
+ f.token.value = '';
4245
+ toast(`✓ Лицензия «${r.state?.planName || ''}» успешно загружена и активирована!`);
4246
+ await applyActivatedState();
4247
+ } catch (err) { toast(err.message, 'error'); }
3969
4248
  });
3970
- box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
4249
+
4250
+ box.querySelector('#form-local-pass')?.addEventListener('submit', async (e) => {
4251
+ e.preventDefault();
4252
+ const pw = e.target.elements.password.value;
4253
+ try {
4254
+ const r = await post('/api/local/password', { password: pw });
4255
+ e.target.reset();
4256
+ toast(r.hasLocalPassword ? '✓ Локальный пароль для удалённого входа сохранён' : 'Локальный пароль сброшен');
4257
+ renderCloudPane();
4258
+ } catch (err) { toast(err.message, 'error'); }
4259
+ });
4260
+
4261
+ box.querySelector('#btn-term-logout')?.addEventListener('click', async () => {
4262
+ await post('/api/auth/logout').catch(() => {});
4263
+ location.href = '/';
4264
+ });
4265
+
4266
+ box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
4267
+ try {
4268
+ await post('/api/notify/test');
4269
+ toast('✓ Тестовое уведомление доставлено');
4270
+ } catch (e) { toast(e.message, 'error'); }
4271
+ });
4272
+
4273
+ box.querySelector('#direct-notify-form')?.addEventListener('submit', async (e) => {
3971
4274
  e.preventDefault();
3972
4275
  const f = e.target.elements;
3973
4276
  try {
3974
- await post('/api/admin/notify', { tgBotName: f.tgBotName.value, tgToken: f.tgToken.value, maxBotName: f.maxBotName.value, maxToken: f.maxToken.value });
3975
- toast('Настройки ботов Telegram и MAX сохранены');
4277
+ await post('/api/notify/config', {
4278
+ usePortalRelay: f.usePortalRelay.checked,
4279
+ tgToken: f.tgToken.value,
4280
+ tgChatId: f.tgChatId.value,
4281
+ maxToken: f.maxToken.value,
4282
+ maxChatId: f.maxChatId.value,
4283
+ });
4284
+ toast('✓ Настройки уведомлений сохранены');
3976
4285
  renderCloudPane();
3977
4286
  } catch (err) { toast(err.message, 'error'); }
3978
4287
  });
@@ -4542,18 +4851,14 @@ document.addEventListener('keydown', (e) => {
4542
4851
  loadNews().catch(() => {});
4543
4852
  renderCloudPane().catch(() => {});
4544
4853
  const urlParams = new URLSearchParams(location.search);
4545
- if (urlParams.get('payment_check') === '1') {
4546
- post('/api/billing/verify').then((r) => {
4547
- if (r.status === 'succeeded') toast(`Оплата прошла успешно! Включён тариф «${r.user?.planName}»`);
4548
- renderCloudPane();
4549
- }).catch(() => {});
4550
- }
4551
4854
  const savedNav = loadSavedNav();
4552
4855
  if (isOwnerUser()) {
4553
4856
  const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
4554
4857
  await loadRuns(initRunId);
4555
4858
  }
4556
- if (urlParams.get('run') && isOwnerUser()) {
4859
+ if (!isLicensed()) {
4860
+ showMainSec('cloud');
4861
+ } else if (urlParams.get('run') && isOwnerUser()) {
4557
4862
  showMainSec('overview');
4558
4863
  } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
4559
4864
  showMainSec(savedNav.mainSec);