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.
- 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 +300 -152
- package/public/index.html +4 -37
- package/server/api/routes.js +570 -906
- package/server/auth/index.js +576 -761
- package/server/brokers/index.js +1 -4
- package/server/config.js +73 -53
- package/server/index.js +186 -187
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/model/dataset.js +2 -4
- package/server/model/forecast.js +1 -3
- package/server/news/index.js +2 -3
- package/server/notify/index.js +304 -466
- 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
|
@@ -1897,9 +1897,15 @@ function shortEndpointHost(urlStr = '') {
|
|
|
1897
1897
|
}
|
|
1898
1898
|
}
|
|
1899
1899
|
|
|
1900
|
-
async function loadLlm() {
|
|
1901
|
-
|
|
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 = '/
|
|
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
|
|
3775
|
-
|
|
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
|
|
3781
|
-
|
|
3795
|
+
function fmtBytesGb(bytes) {
|
|
3796
|
+
return `${((Number(bytes) || 0) / (1024 ** 3)).toFixed(2)} ГБ`;
|
|
3782
3797
|
}
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
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
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
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
|
-
// ----------
|
|
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?.
|
|
3822
|
-
const
|
|
3823
|
-
|
|
3824
|
-
|
|
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"
|
|
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
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
${
|
|
3893
|
-
${(
|
|
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"
|
|
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
|
-
|
|
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
|
-
<
|
|
3906
|
-
<input
|
|
3907
|
-
<button type="
|
|
3908
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3930
|
-
|
|
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-
|
|
3934
|
-
|
|
3935
|
-
|
|
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
|
-
|
|
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/
|
|
3940
|
-
|
|
4099
|
+
const r = await post('/api/local/password', { password: pw });
|
|
4100
|
+
e.target.reset();
|
|
4101
|
+
toast(r.hasLocalPassword ? '✓ Локальный пароль для удалённого входа сохранён' : 'Локальный пароль сброшен (оставлен вход по токену ЛК)');
|
|
3941
4102
|
renderCloudPane();
|
|
3942
|
-
} catch (
|
|
4103
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
3943
4104
|
});
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
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/
|
|
3956
|
-
toast('Тестовое уведомление
|
|
4113
|
+
await post('/api/notify/test');
|
|
4114
|
+
toast('✓ Тестовое уведомление доставлено');
|
|
3957
4115
|
} catch (e) { toast(e.message, 'error'); }
|
|
3958
4116
|
});
|
|
3959
|
-
|
|
3960
|
-
|
|
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/
|
|
3975
|
-
|
|
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;
|