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.
- package/README.md +14 -10
- package/bin/cli.js +79 -69
- 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
- package/package.json +4 -5
- package/public/activate.html +41 -19
- package/public/activate.js +81 -10
- package/public/app.js +471 -166
- package/public/index.html +9 -39
- package/public/styles.css +42 -3
- package/server/api/routes.js +595 -906
- package/server/auth/index.js +583 -761
- package/server/brokers/index.js +14 -3
- package/server/config.js +73 -53
- package/server/index.js +186 -212
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/model/live.js +2 -1
- package/server/notify/index.js +304 -466
- package/server/strategist/index.js +4 -1
- package/deploy/docs/01-central.md +0 -96
- package/deploy/docs/02-cloud-instances.md +0 -130
- package/deploy/docs/03-desktop-build.md +0 -92
- package/deploy/docs/04-client-guide.md +0 -101
- package/deploy/docs/05-operations.md +0 -109
- package/deploy/docs/06-env-reference.md +0 -91
- package/deploy/docs/07-stress-test.md +0 -253
- package/deploy/docs/pdf/01-central.pdf +0 -0
- package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
- package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
- package/deploy/docs/pdf/04-client-guide.pdf +0 -0
- package/deploy/docs/pdf/05-operations.pdf +0 -0
- package/deploy/docs/pdf/06-env-reference.pdf +0 -0
- package/deploy/docs/pdf/07-stress-test.pdf +0 -0
- package/deploy/install.sh +0 -128
- package/deploy/k8s/instance.yaml +0 -147
- package/public/admin.html +0 -604
- package/public/landing.html +0 -1207
- package/public/legal.html +0 -134
- package/scripts/set-admin-password.mjs +0 -30
- package/server/billing/yookassa.js +0 -142
- package/server/instance/agent.js +0 -259
- package/server/instance/central.js +0 -514
- package/server/instance/identity.js +0 -79
- package/server/instance/provision.js +0 -166
- package/server/instance/signing.js +0 -71
- 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
|
-
|
|
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 = '/
|
|
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
|
|
3775
|
-
|
|
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
|
|
3781
|
-
|
|
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
|
|
3800
|
-
|
|
3801
|
-
if (
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
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
|
-
|
|
3809
|
-
$('#
|
|
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
|
|
3819
|
-
|
|
3820
|
-
|
|
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
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
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"
|
|
4103
|
+
<div class="slbl">Состояние лицензии терминала: ${statusHtml}</div>
|
|
3887
4104
|
<div class="hint">
|
|
3888
|
-
|
|
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
|
-
${
|
|
3893
|
-
${
|
|
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"
|
|
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="
|
|
3907
|
-
<
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3930
|
-
|
|
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('#
|
|
3934
|
-
|
|
3935
|
-
|
|
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
|
-
|
|
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
|
|
3940
|
-
|
|
3941
|
-
|
|
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
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
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/
|
|
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
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
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
|
-
|
|
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/
|
|
3975
|
-
|
|
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 (
|
|
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);
|