neuroalpha 0.6.12 → 0.7.0

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 +300 -152
  8. package/public/index.html +4 -37
  9. package/server/api/routes.js +570 -906
  10. package/server/auth/index.js +576 -761
  11. package/server/brokers/index.js +1 -4
  12. package/server/config.js +73 -53
  13. package/server/index.js +186 -187
  14. package/server/llm/local-engine.js +843 -0
  15. package/server/llm/providers.js +45 -34
  16. package/server/model/dataset.js +2 -4
  17. package/server/model/forecast.js +1 -3
  18. package/server/news/index.js +2 -3
  19. package/server/notify/index.js +304 -466
  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
@@ -1897,9 +1897,15 @@ function shortEndpointHost(urlStr = '') {
1897
1897
  }
1898
1898
  }
1899
1899
 
1900
- async function loadLlm() {
1901
- state.llm = await api('/api/llm/providers');
1900
+ async function loadLlm(refreshHw = false) {
1901
+ const [prov, loc] = await Promise.all([
1902
+ api('/api/llm/providers'),
1903
+ api(`/api/llm/local/state${refreshHw ? '?refresh=1' : ''}`).catch(() => null),
1904
+ ]);
1905
+ state.llm = prov;
1906
+ if (loc) state.localLlm = loc;
1902
1907
  renderLlm();
1908
+ renderLocalLlmBox();
1903
1909
  }
1904
1910
 
1905
1911
  async function selectActiveLlm(pid, mid) {
@@ -1994,6 +2000,7 @@ function renderLlm() {
1994
2000
  const isProvActive = active?.providerId === p.id;
1995
2001
  const usable = p.hasKey || !p.needsKey;
1996
2002
  const isCloud = p.id === 'neuroalpha_cloud' || p.source === 'cloud';
2003
+ const isLocalManaged = p.id === 'local_llama' || p.source === 'local';
1997
2004
  const keyBadge = p.needsKey
1998
2005
  ? (p.hasKey
1999
2006
  ? `<span class="tag ok" title="Ключ сохранён на сервере (${esc(p.keySource)})">✓ Ключ: ${esc(p.keySource || 'подключён')}</span>`
@@ -2014,9 +2021,9 @@ function renderLlm() {
2014
2021
  </div>
2015
2022
  <span class="grow"></span>
2016
2023
  <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>'}
2024
+ ${isLocalManaged ? '' : '<button type="button" data-act="remote" title="Запросить у сервера список всех доступных моделей">🔍 Модели на сервере</button>'}
2025
+ ${isLocalManaged ? '' : '<button type="button" data-act="edit" title="Изменить адрес, ключ или параметры подключения">⚙ Настроить</button>'}
2026
+ ${(isCloud || isLocalManaged) ? '' : '<button type="button" data-act="del" class="icon" style="color:var(--muted)" title="Удалить подключение">🗑</button>'}
2020
2027
  </div>
2021
2028
  <div class="llm-meta">
2022
2029
  <span>Адрес: <code title="${esc(p.baseUrl)}">${esc(shortEndpointHost(p.baseUrl))}</code></span>
@@ -3605,8 +3612,8 @@ function connectWs() {
3605
3612
  ws.onclose = (ev) => {
3606
3613
  connected = false;
3607
3614
  if (ev.code === 4401) {
3608
- setStatus('bad', 'Сессия не авторизована — войдите в аккаунт');
3609
- location.href = '/landing';
3615
+ setStatus('bad', 'Требуется активация или вход в терминал');
3616
+ location.href = '/';
3610
3617
  return;
3611
3618
  }
3612
3619
  setStatus('bad', 'Нет связи с сервером — запустите его (F5 в VS Code)');
@@ -3765,47 +3772,187 @@ function connectWs() {
3765
3772
  stabilityUi(null);
3766
3773
  toast(`Проверка устойчивости №${m.runId}: ${m.error}`, m.error === 'Отменено' ? 'info' : 'error');
3767
3774
  break;
3775
+ case 'localLlm:status': {
3776
+ const prevPhase = state.localLlm?.job?.phase;
3777
+ if (state.localLlm) {
3778
+ state.localLlm = { ...state.localLlm, ...m.status };
3779
+ renderLocalLlmBox();
3780
+ }
3781
+ if (m.status?.job?.phase === 'running' && prevPhase !== 'running') {
3782
+ loadLlm().catch(() => {});
3783
+ }
3784
+ break;
3785
+ }
3768
3786
  case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
3769
3787
  }
3770
3788
  };
3771
3789
  }
3772
3790
 
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');
3791
+ // ---------- Менеджер локальной модели ИИ на этом ПК (llama.cpp + Qwen3 GGUF, с поддержкой AMD Ryzen AI Max+ 395/495) ----------
3792
+ let selectedLocalModelId = '';
3793
+ let selectedLocalBackend = '';
3779
3794
 
3780
- function syncTermOfferBtn() {
3781
- if (termOfferAccept) termOfferAccept.disabled = !(termChk1?.checked && termChk2?.checked);
3795
+ function fmtBytesGb(bytes) {
3796
+ return `${((Number(bytes) || 0) / (1024 ** 3)).toFixed(2)} ГБ`;
3782
3797
  }
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();
3798
+
3799
+ function fmtEta(sec) {
3800
+ if (!Number.isFinite(sec) || sec === null || sec < 0) return '';
3801
+ if (sec < 60) return `~${sec} сек`;
3802
+ const m = Math.floor(sec / 60);
3803
+ const s = sec % 60;
3804
+ return m >= 60 ? `~${Math.floor(m / 60)} ч ${m % 60} мин` : `~${m} мин ${s} сек`;
3805
+ }
3806
+
3807
+ function renderLocalLlmBox() {
3808
+ const box = $('#local-llm-box');
3809
+ if (!box) return;
3810
+ const loc = state.localLlm;
3811
+ if (!loc?.hardware) {
3812
+ box.innerHTML = '';
3813
+ return;
3796
3814
  }
3797
- });
3815
+ const hw = loc.hardware;
3816
+ const job = loc.job || { phase: 'idle' };
3817
+ const isBusy = job.phase === 'engine' || job.phase === 'model' || job.phase === 'starting';
3818
+
3819
+ if (!selectedLocalModelId || !loc.models?.some((m) => m.id === selectedLocalModelId)) {
3820
+ selectedLocalModelId = loc.activeModelId || loc.recommendedModelId || loc.models?.[0]?.id || '';
3821
+ }
3822
+ if (!selectedLocalBackend || !(hw.availableBackends || []).includes(selectedLocalBackend)) {
3823
+ selectedLocalBackend = loc.activeBackend || hw.recommendedBackend || 'cpu';
3824
+ }
3825
+
3826
+ const selModel = (loc.models || []).find((m) => m.id === selectedLocalModelId) || loc.models?.[0];
3827
+ const hwBadge = hw.isRyzenAiMax
3828
+ ? '<span class="tag ok">⚡ AMD Ryzen AI Max+ (Unified Memory)</span>'
3829
+ : hw.isUnifiedMemory
3830
+ ? '<span class="tag ok">⚡ Unified Memory GPU</span>'
3831
+ : hw.usableVramGb >= 6
3832
+ ? `<span class="tag ok">GPU ${hw.usableVramGb} ГБ VRAM</span>`
3833
+ : '<span class="tag">CPU-режим</span>';
3834
+
3835
+ const ollamaHint = hw.ollama?.running
3836
+ ? `<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>`
3837
+ : '';
3838
+
3839
+ let bodyHtml = '';
3840
+ if (!hw.canInstall) {
3841
+ bodyHtml = `<div class="muted small" style="margin-top:8px;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:var(--bg2)">
3842
+ ⚠ <b>Локальная модель недоступна на этом устройстве:</b> ${esc(hw.reasonBlocked || 'недостаточно памяти или места на диске')}<br>
3843
+ Рекомендуемая конфигурация для локального ИИ-Стратега максимального уровня — рабочая станция на базе <b>AMD Ryzen AI Max+ 395 / 495</b> (64–128 ГБ единой памяти).
3844
+ </div>`;
3845
+ } else {
3846
+ const progressHtml = isBusy
3847
+ ? `<div style="margin-top:12px;padding:10px 12px;border:1px solid var(--accent);border-radius:8px;background:var(--bg2)">
3848
+ <div style="display:flex;justify-content:space-between;gap:8px;font-size:13px;margin-bottom:6px">
3849
+ <b>${esc(job.message || 'Установка…')}</b>
3850
+ <span>${job.percent || 0}%${job.speedMbSec ? ` · ${job.speedMbSec} МБ/с` : ''}${job.etaSec ? ` · осталось ${fmtEta(job.etaSec)}` : ''}</span>
3851
+ </div>
3852
+ <div style="height:8px;border-radius:999px;background:var(--line);overflow:hidden">
3853
+ <div style="height:100%;width:${Math.max(2, job.percent || 0)}%;background:var(--accent);transition:width .25s"></div>
3854
+ </div>
3855
+ ${job.totalBytes > 0 ? `<div class="muted small" style="margin-top:5px">${fmtBytesGb(job.downloadedBytes)} из ${fmtBytesGb(job.totalBytes)} · при прерывании закачка продолжится с того же места</div>` : ''}
3856
+ </div>`
3857
+ : (job.error ? `<div class="small neg" style="margin-top:8px">⚠ ${esc(job.error)}</div>` : '');
3858
+
3859
+ bodyHtml = `
3860
+ <div style="display:grid;gap:10px;margin-top:10px">
3861
+ <div class="row-gap" style="flex-wrap:wrap">
3862
+ <select id="local-llm-model-sel" style="flex:1;min-width:260px" ${isBusy ? 'disabled' : ''}>
3863
+ ${(loc.models || []).map((m) => {
3864
+ const rec = m.id === loc.recommendedModelId ? ' ★ рекомендуется под ваш ПК' : '';
3865
+ const inst = m.installed ? ' [скачана ✓]' : ` [~${m.sizeGb} ГБ]`;
3866
+ const incomp = !m.compatible ? ' — мало памяти/диска' : '';
3867
+ return `<option value="${esc(m.id)}" ${m.id === selectedLocalModelId ? 'selected' : ''} ${!m.compatible && !m.installed ? 'disabled' : ''}>${esc(m.label)}${inst}${rec}${incomp}</option>`;
3868
+ }).join('')}
3869
+ </select>
3870
+ <select id="local-llm-backend-sel" style="width:170px" title="Вычислительный движок llama-server" ${isBusy ? 'disabled' : ''}>
3871
+ ${(hw.availableBackends || ['cpu']).map((b) => `<option value="${esc(b)}" ${b === selectedLocalBackend ? 'selected' : ''}>Движок: ${esc(b.toUpperCase())}</option>`).join('')}
3872
+ </select>
3873
+ </div>
3874
+ ${selModel ? `<div class="muted small">${esc(selModel.desc)} · Режим ускорения: <b>${esc(selModel.accelMode)}</b></div>` : ''}
3875
+ <div class="row-gap" style="flex-wrap:wrap">
3876
+ ${isBusy
3877
+ ? `<button type="button" id="btn-local-llm-cancel">⏸ Приостановить загрузку</button>`
3878
+ : (loc.running && loc.activeModelId === selectedLocalModelId
3879
+ ? `<span class="tag ok">● Запущена на 127.0.0.1:${loc.port}</span>
3880
+ <button type="button" id="btn-local-llm-stop">⏹ Остановить локальный движок</button>`
3881
+ : (selModel?.installed
3882
+ ? `<button type="button" class="primary" id="btn-local-llm-start">▶ Запустить и выбрать для Стратега</button>`
3883
+ : `<button type="button" class="primary" id="btn-local-llm-install" ${!selModel?.compatible ? 'disabled' : ''}>⬇ Скачать (~${selModel?.sizeGb || ''} ГБ) и включить для Стратега</button>`))}
3884
+ ${(!isBusy && selModel?.installed) ? `<button type="button" id="btn-local-llm-del" style="color:var(--red)">🗑 Удалить файл весов с диска</button>` : ''}
3885
+ <button type="button" id="btn-local-llm-rescan" class="small" ${isBusy ? 'disabled' : ''}>↻ Перепроверить железо</button>
3886
+ </div>
3887
+ ${progressHtml}
3888
+ </div>`;
3889
+ }
3890
+
3891
+ box.innerHTML = `<div class="llm-prov-card" style="border-color:rgba(59,130,246,0.45)">
3892
+ <div class="llm-head">
3893
+ <div class="llm-title">
3894
+ <span>🖥 Автономная локальная модель для Стратега (на этом ПК)</span>
3895
+ ${hwBadge}
3896
+ </div>
3897
+ </div>
3898
+ <div class="llm-meta" style="margin-top:4px">
3899
+ Процессор: <b>${esc(hw.cpuModel)}</b> (${hw.cpuCores} потоков) · ОЗУ: <b>${hw.totalRamGb} ГБ</b>${hw.gpuName ? ` · Графика: <b>${esc(hw.gpuName)}</b> (доступно ИИ: ${hw.usableVramGb} ГБ)` : ''} · Свободно на диске: <b>${hw.freeDiskGb} ГБ</b>
3900
+ </div>
3901
+ ${!hw.isRyzenAiMax ? '<div class="muted small" style="margin-top:3px">Для максимальных локальных моделей 30B–80B мы рекомендуем мини-станции на базе <b>AMD Ryzen AI Max+ 395 / 495</b> (единая память 64–128 ГБ).</div>' : ''}
3902
+ ${ollamaHint}
3903
+ ${bodyHtml}
3904
+ </div>`;
3798
3905
 
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();
3906
+ box.querySelector('#local-llm-model-sel')?.addEventListener('change', (e) => {
3907
+ selectedLocalModelId = e.target.value;
3908
+ renderLocalLlmBox();
3909
+ });
3910
+ box.querySelector('#local-llm-backend-sel')?.addEventListener('change', (e) => {
3911
+ selectedLocalBackend = e.target.value;
3912
+ });
3913
+ box.querySelector('#btn-local-llm-install')?.addEventListener('click', async () => {
3914
+ try {
3915
+ const st = await post('/api/llm/local/install', { modelId: selectedLocalModelId, backend: selectedLocalBackend });
3916
+ state.localLlm = { ...state.localLlm, ...st };
3917
+ renderLocalLlmBox();
3918
+ } catch (e) { toast(e.message, 'error'); }
3919
+ });
3920
+ box.querySelector('#btn-local-llm-start')?.addEventListener('click', async () => {
3921
+ try {
3922
+ toast('Запускаем локальный движок llama-server и загружаем модель в память…');
3923
+ await post('/api/llm/local/start', { modelId: selectedLocalModelId, backend: selectedLocalBackend });
3924
+ await loadLlm();
3925
+ showLlmStatus();
3926
+ toast('✓ Локальная модель запущена и назначена активной для Стратега!');
3927
+ } catch (e) { toast(e.message, 'error'); }
3928
+ });
3929
+ box.querySelector('#btn-local-llm-stop')?.addEventListener('click', async () => {
3930
+ try {
3931
+ await post('/api/llm/local/stop');
3932
+ await loadLlm();
3933
+ showLlmStatus();
3934
+ toast('Локальный движок ИИ остановлен');
3935
+ } catch (e) { toast(e.message, 'error'); }
3936
+ });
3937
+ box.querySelector('#btn-local-llm-cancel')?.addEventListener('click', async () => {
3938
+ try {
3939
+ const st = await post('/api/llm/local/cancel');
3940
+ state.localLlm = { ...state.localLlm, ...st };
3941
+ renderLocalLlmBox();
3942
+ } catch (e) { toast(e.message, 'error'); }
3943
+ });
3944
+ box.querySelector('#btn-local-llm-del')?.addEventListener('click', async () => {
3945
+ if (!confirm('Удалить скачанный .gguf файл этой модели с диска?')) return;
3946
+ try {
3947
+ await del(`/api/llm/local/model/${encodeURIComponent(selectedLocalModelId)}`);
3948
+ await loadLlm(true);
3949
+ toast('Файл модели удалён с диска');
3950
+ } catch (e) { toast(e.message, 'error'); }
3951
+ });
3952
+ box.querySelector('#btn-local-llm-rescan')?.addEventListener('click', () => loadLlm(true));
3806
3953
  }
3807
3954
 
3808
- // ---------- профиль, тарифы ЮKassa, VIP-токен и боты Telegram/MAX ----------
3955
+ // ---------- Автономная лицензия терминала, защита VPS и шлюз уведомлений Telegram / MAX ----------
3809
3956
  $('#btn-user')?.addEventListener('click', () => {
3810
3957
  switchTab('settings');
3811
3958
  showSettingsSec('cloud');
@@ -3818,161 +3965,168 @@ async function renderCloudPane() {
3818
3965
  const me = await api('/api/auth/me').catch(() => state.config || {});
3819
3966
  state.config = { ...(state.config || {}), ...me };
3820
3967
  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 || ''));
3968
+ const lic = me.license || u?.license || {};
3969
+ const nt = me.notify || {};
3970
+
3971
+ const dispName = lic.name || u?.name || 'Пользователь';
3829
3972
  const userBtn = $('#btn-user');
3830
- if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Войти';
3973
+ if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Лицензия';
3974
+ updateTabBadges();
3831
3975
 
3832
3976
  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>`;
3977
+ 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="/">Открыть экран активации</a></div></div>`;
3834
3978
  return;
3835
3979
  }
3836
3980
 
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>
3850
- </div>
3851
- <div class="row-gap" style="flex-wrap:wrap">
3852
- <a class="btn primary" href="/admin">Открыть админ-консоль (/admin) →</a>
3853
- </div>
3854
- </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
- }
3874
-
3981
+ const validStr = lic.devBypass
3982
+ ? 'режим разработчика (локальный git-репозиторий)'
3983
+ : lic.perpetual
3984
+ ? 'действует бессрочно (100% автономно)'
3985
+ : lic.validUntil
3986
+ ? `автономно оплачена до <b>${fmtDate(lic.validUntil)}</b>`
3987
+ : 'не активна';
3875
3988
  const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
3876
- ? 'по согласованию / без ограничений'
3989
+ ? 'без ограничений'
3877
3990
  : u.limits?.maxCapitalPerAccountRub > 0
3878
- ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ реинвестирование прибыли)`
3879
- : 'подписка не активна';
3880
- const dirLimStr = u.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)';
3881
- updateTabBadges();
3991
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ вся прибыль)`
3992
+ : 'нет активной лицензии';
3993
+ const dirLimStr = u.limits?.allowShorts ? 'покупки и шорты (Long / Short)' : 'только покупки (Long)';
3994
+ const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
3882
3995
 
3883
3996
  box.innerHTML = `
3884
3997
  <div class="srow sblock">
3885
3998
  <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>
3999
+ <div class="slbl">
4000
+ Лицензия терминала: <span class="tag ${lic.valid ? 'ok' : 'no'}" style="margin-left:6px">${esc(u.planName)}</span>
4001
+ ${lic.label ? `<span class="muted small" style="margin-left:6px">(${esc(lic.label)})</span>` : ''}
4002
+ </div>
3887
4003
  <div class="hint">
3888
- Брокерских счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим торговли: <b>${dirLimStr}</b>${u.planUntil ? ` · Оплачен до ${fmtDate(u.planUntil)}` : ''}.
4004
+ Статус: ${validStr}${lic.email ? ` · Аккаунт: <b>${esc(lic.email)}</b>` : ''}<br>
4005
+ Квота счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Направление: <b>${dirLimStr}</b>.<br>
4006
+ Сертификат подписан ключом портала (Ed25519) и работает полностью автономно без обращений к сайту до последних 3 суток перед окончанием срока.
3889
4007
  </div>
3890
4008
  </div>
3891
4009
  <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>` : ''}
4010
+ ${lic.hasCabinetToken ? '<button type="button" class="primary" id="btn-lic-refresh">↻ Обновить лицензию с портала</button>' : ''}
4011
+ <a class="btn" href="${esc(portalUrl)}" target="_blank" rel="noopener">Личный кабинет на портале →</a>
4012
+ ${!me.isLoopback ? '<button type="button" id="btn-term-logout" style="color:var(--red)">Выйти из сессии</button>' : ''}
3894
4013
  </div>
3895
4014
  </div>
4015
+
3896
4016
  <div class="srow sblock">
3897
4017
  <div class="stxt">
3898
- <div class="slbl">Тарифные планы и активация персонального VIP-токена / промокода</div>
4018
+ <div class="slbl">Активировать или сменить токен личного кабинета (<code>na_tok_…</code>)</div>
4019
+ <div class="hint">После продления тарифа или покупки новой лицензии на портале вставьте ваш токен ниже (или нажмите «Обновить лицензию», если токен уже сохранён).</div>
4020
+ </div>
4021
+ <form id="form-lic-bind" class="row-gap" style="flex-wrap:wrap">
4022
+ <input name="token" placeholder="na_tok_…" style="width:280px;font-family:monospace" required>
4023
+ <input name="portalUrl" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" style="width:230px">
4024
+ <button type="submit" class="primary">Скачать лицензию</button>
4025
+ </form>
4026
+ </div>
4027
+
4028
+ <div class="srow sblock">
4029
+ <div class="stxt">
4030
+ <div class="slbl">Защита удалённого доступа (для работы на арендованной виртуалке / VPS)</div>
3899
4031
  <div class="hint">
3900
- • <b>«Инвестор» (3 900 ₽/мес)</b> — 1 брокерский счёт со стартовым лимитом до 1 млн ₽ (+ реинвестирование всей прибыли), торговля «Только в рост (без шорта)»;<br>
3901
- • <b>«Профи» (9 900 ₽/мес)</b> — до 5 брокерских счетов со стартовым лимитом до 1 млн ₽ на каждом (+ реинвестирование всей прибыли), торговля «В рост и в падение (с шортом)»;<br>
3902
- • <b>«VIP» (по индивидуальному согласованию)</b> — количество счетов и начальная сумма торговли по согласованию, торговля с шортами.
4032
+ С этого же компьютера (<code>localhost</code>) терминал открывается сразу. При обращении по сети / IP сервер требует ввести ваш токен <code>na_tok_…</code> либо дополнительный локальный пароль (${lic.hasLocalPassword ? '<b class="pos">установлен ✓</b>' : 'не задан'}).
3903
4033
  </div>
3904
4034
  </div>
3905
- <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>
3911
- </div>
4035
+ <form id="form-local-pass" class="row-gap" style="flex-wrap:wrap">
4036
+ <input name="password" type="password" autocomplete="new-password" placeholder="${lic.hasLocalPassword ? 'Новый пароль (пусто — сбросить)' : 'Задать пароль (от 6 симв.)'}" style="width:240px">
4037
+ <button type="submit">Сохранить пароль VPS</button>
4038
+ </form>
3912
4039
  </div>
4040
+
3913
4041
  <div class="srow sblock">
3914
4042
  <div class="stxt">
3915
4043
  <div class="slbl">Уведомления и общение со Стратегом в Telegram и MAX</div>
3916
4044
  <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>'}
4045
+ По умолчанию уведомления и кнопки подтверждения заявок работают «из коробки» через официальных ботов портала (привяжите Telegram или MAX в личном кабинете на <a href="${esc(portalUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">${esc(portalUrl.replace(/^https?:\/\//, ''))}</a>). При желании ниже можно подключить собственного бота напрямую без участия портала.
3920
4046
  </div>
3921
4047
  </div>
3922
4048
  <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>
4049
+ <button type="button" id="btn-notify-test">✉ Отправить тест уведомления</button>
3927
4050
  </div>
3928
4051
  </div>
3929
- ${adminLinkHtml}
3930
- ${customBotsHtml}
4052
+
4053
+ <details style="margin-top:8px;padding-top:10px;border-top:1px solid var(--line)">
4054
+ <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Прямое подключение собственного личного бота Telegram / MAX (автономно без портала)</summary>
4055
+ <form id="direct-notify-form" style="display:grid;gap:10px;margin-top:10px">
4056
+ <label class="row-gap small"><input type="checkbox" name="usePortalRelay" ${nt.usePortalRelay !== false ? 'checked' : ''}> Разрешить отправку через ботов портала, если прямой бот не заполнен</label>
4057
+ <div class="row-gap" style="flex-wrap:wrap">
4058
+ <input name="tgToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectTg ? 'Токен личного Telegram-бота задан' : 'Токен Telegram Bot API (@BotFather)'}" style="flex:1;min-width:240px">
4059
+ <input name="tgChatId" value="${esc(nt.tgChatId || '')}" placeholder="Telegram Chat ID (заполнится при /start)" style="width:240px">
4060
+ </div>
4061
+ <div class="row-gap" style="flex-wrap:wrap">
4062
+ <input name="maxToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectMax ? 'Токен личного MAX-бота задан' : 'Токен MAX Bot API'}" style="flex:1;min-width:240px">
4063
+ <input name="maxChatId" value="${esc(nt.maxChatId || '')}" placeholder="MAX Chat ID" style="width:240px">
4064
+ <button type="submit" class="primary">Сохранить прямого бота</button>
4065
+ </div>
4066
+ </form>
4067
+ </details>
3931
4068
  `;
3932
4069
 
3933
- box.querySelector('#btn-cloud-logout')?.addEventListener('click', async () => {
3934
- const r = await post('/api/auth/logout').catch(() => ({}));
3935
- location.href = r?.redirect || '/landing';
4070
+ box.querySelector('#btn-lic-refresh')?.addEventListener('click', async () => {
4071
+ try {
4072
+ const r = await post('/api/local/refresh-license', {});
4073
+ toast(`✓ Лицензия обновлена: ${r.state?.planName || 'активна'}`);
4074
+ await renderCloudPane();
4075
+ await loadLlm();
4076
+ } catch (e) { toast(e.message, 'error'); }
4077
+ });
4078
+
4079
+ box.querySelector('#form-lic-bind')?.addEventListener('submit', async (e) => {
4080
+ e.preventDefault();
4081
+ const f = e.target.elements;
4082
+ try {
4083
+ const r = await post('/api/local/activate', { token: f.token.value.trim(), portalUrl: f.portalUrl.value.trim() });
4084
+ if (r.chooseSlot && r.slots?.length) {
4085
+ const firstId = r.slots[0].id;
4086
+ await post('/api/local/activate', { token: f.token.value.trim(), portalUrl: f.portalUrl.value.trim(), slotId: firstId });
4087
+ }
4088
+ f.token.value = '';
4089
+ toast('✓ Автономная лицензия успешно скачана и активирована!');
4090
+ await renderCloudPane();
4091
+ await loadLlm();
4092
+ } catch (err) { toast(err.message, 'error'); }
3936
4093
  });
3937
- box.querySelector('#btn-cloud-verify')?.addEventListener('click', async () => {
4094
+
4095
+ box.querySelector('#form-local-pass')?.addEventListener('submit', async (e) => {
4096
+ e.preventDefault();
4097
+ const pw = e.target.elements.password.value;
3938
4098
  try {
3939
- const r = await post('/api/billing/verify');
3940
- toast(r.status === 'succeeded' ? `Оплата подтверждена! Тариф: ${r.user?.planName}` : `Статус платежа: ${r.status}`);
4099
+ const r = await post('/api/local/password', { password: pw });
4100
+ e.target.reset();
4101
+ toast(r.hasLocalPassword ? '✓ Локальный пароль для удалённого входа сохранён' : 'Локальный пароль сброшен (оставлен вход по токену ЛК)');
3941
4102
  renderCloudPane();
3942
- } catch (e) { toast(e.message, 'error'); }
4103
+ } catch (err) { toast(err.message, 'error'); }
3943
4104
  });
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
- }));
4105
+
4106
+ box.querySelector('#btn-term-logout')?.addEventListener('click', async () => {
4107
+ await post('/api/auth/logout').catch(() => {});
4108
+ location.href = '/';
4109
+ });
4110
+
3953
4111
  box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
3954
4112
  try {
3955
- await post('/api/auth/notify-test');
3956
- toast('Тестовое уведомление отправлено в привязанные мессенджеры');
4113
+ await post('/api/notify/test');
4114
+ toast('✓ Тестовое уведомление доставлено');
3957
4115
  } catch (e) { toast(e.message, 'error'); }
3958
4116
  });
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
- });
3969
- });
3970
- box.querySelector('#adm-notify-form')?.addEventListener('submit', async (e) => {
4117
+
4118
+ box.querySelector('#direct-notify-form')?.addEventListener('submit', async (e) => {
3971
4119
  e.preventDefault();
3972
4120
  const f = e.target.elements;
3973
4121
  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 сохранены');
4122
+ await post('/api/notify/config', {
4123
+ usePortalRelay: f.usePortalRelay.checked,
4124
+ tgToken: f.tgToken.value,
4125
+ tgChatId: f.tgChatId.value,
4126
+ maxToken: f.maxToken.value,
4127
+ maxChatId: f.maxChatId.value,
4128
+ });
4129
+ toast('✓ Настройки уведомлений сохранены');
3976
4130
  renderCloudPane();
3977
4131
  } catch (err) { toast(err.message, 'error'); }
3978
4132
  });
@@ -4542,12 +4696,6 @@ document.addEventListener('keydown', (e) => {
4542
4696
  loadNews().catch(() => {});
4543
4697
  renderCloudPane().catch(() => {});
4544
4698
  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
4699
  const savedNav = loadSavedNav();
4552
4700
  if (isOwnerUser()) {
4553
4701
  const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;