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
@@ -5,7 +5,7 @@ import https from 'node:https';
5
5
  import path from 'node:path';
6
6
  import { ROOT } from '../config.js';
7
7
  import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
8
- import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
8
+ import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser, tradingGate } from '../auth/index.js';
9
9
  import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
10
10
  import { getSettings, normalizeParams } from '../settings.js';
11
11
  import { latestBarTs, registerRealtimeCandleFetcher } from '../data/moex.js';
@@ -22,7 +22,6 @@ import { blockingNews } from '../news/index.js';
22
22
  import { round, MSK, DAY, fmtBarMsk } from '../util.js';
23
23
  import { hub } from '../hub.js';
24
24
  import { TRUSTED_CA } from '../tls-ca.js';
25
- import { tradingGate } from '../instance/agent.js';
26
25
 
27
26
  export const FULLY_SUPPORTED_BROKERS = ['tbank'];
28
27
  const MANUAL_BROKERS = ['sber', 'vtb', 'alfa'];
@@ -191,7 +190,6 @@ function logBrokerEvent(acc, kind, text) {
191
190
  const uid = currentUserId();
192
191
  db.prepare('INSERT INTO broker_events (ts, user_id, account_id, kind, text) VALUES (?, ?, ?, ?, ?)')
193
192
  .run(Date.now(), uid, String(acc?.id || ''), String(kind), String(text || '').slice(0, 500));
194
- if (/error|stop|halt/.test(kind)) console.warn(`[брокер ${acc?.name || acc?.id}] ${kind}: ${text}`);
195
193
  // храним последние 5000 событий на счёт
196
194
  if (Math.random() < 0.02) {
197
195
  db.prepare(`DELETE FROM broker_events WHERE user_id = ? AND account_id = ? AND id NOT IN
@@ -275,7 +273,6 @@ function migrateKvAccounts(uid) {
275
273
  db.exec('COMMIT');
276
274
  } catch (e) { db.exec('ROLLBACK'); throw e; }
277
275
  // прежняя запись не удаляется: при откате на версию до 0.4.1 счета останутся на месте (в состоянии на момент переноса)
278
- console.log(`✓ Брокерские счета пользователя ${uid} перенесены в отдельные записи (${old.length})`);
279
276
  }
280
277
  userKvSet('brokerAccountsMigrated', true, uid);
281
278
  }
package/server/config.js CHANGED
@@ -1,53 +1,73 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
-
6
- import { IS_PACKAGED, BUILD } from './build-info.js';
7
-
8
- // В защищённой сборке файлы интерфейса распаковываются во временный каталог (NEUROALPHA_ROOT задаёт загрузчик)
9
- export const ROOT = IS_PACKAGED
10
- ? path.resolve(process.env.NEUROALPHA_ROOT || process.cwd())
11
- : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
- export const DATA_DIR = process.env.NEUROALPHA_DATA_DIR
13
- ? path.resolve(process.env.NEUROALPHA_DATA_DIR)
14
- : path.join(ROOT, 'data');
15
-
16
- if (!IS_PACKAGED) {
17
- const envFile = path.join(ROOT, '.env');
18
- if (fs.existsSync(envFile)) process.loadEnvFile(envFile);
19
- const cwdEnv = path.resolve(process.cwd(), '.env');
20
- if (cwdEnv !== envFile && fs.existsSync(cwdEnv)) process.loadEnvFile(cwdEnv);
21
- }
22
-
23
- export const strategyDefaults = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/strategy.json'), 'utf8'));
24
-
25
- function resolveApiKey() {
26
- if (process.env.LLM_API_KEY) return process.env.LLM_API_KEY.trim();
27
- const file = process.env.LLM_API_KEY_FILE;
28
- if (!file) return '';
29
- try {
30
- const raw = fs.readFileSync(path.resolve(ROOT, file), 'utf8').trim();
31
- if (raw.startsWith('{')) {
32
- const j = JSON.parse(raw);
33
- return String(j.apiKey || j.api_key || j.key || '').trim();
34
- }
35
- return raw;
36
- } catch {
37
- return '';
38
- }
39
- }
40
-
41
- export const llmConfig = {
42
- provider: (process.env.LLM_PROVIDER || 'openai').toLowerCase(),
43
- baseUrl: process.env.LLM_BASE_URL || 'https://models.odant.org/v1/chat/completions',
44
- model: process.env.LLM_MODEL || 'Qwen/Qwen3.8-27B-FP8',
45
- apiKey: resolveApiKey(),
46
- reasoningEffort: process.env.LLM_REASONING_EFFORT || 'low',
47
- maxTokens: Number(process.env.LLM_MAX_TOKENS || 4000),
48
- timeoutMs: Number(process.env.LLM_TIMEOUT_MS || 90000),
49
- };
50
- llmConfig.enabled = llmConfig.provider !== 'none' && !!llmConfig.baseUrl && !!llmConfig.model && !!llmConfig.apiKey;
51
-
52
- export const PORT = Number(process.env.PORT || 3000);
53
- export const DEFAULT_CLOUD_URL = ((IS_PACKAGED ? BUILD.centralUrl : process.env.CLOUD_URL) || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { IS_PACKAGED, BUILD } from './build-info.js';
7
+
8
+ export const ROOT = IS_PACKAGED
9
+ ? path.resolve(process.env.NEUROALPHA_ROOT || process.cwd())
10
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+
12
+ // Признак запуска из локального git-репозитория разработчика (вне node_modules / кэша npx)
13
+ const insideNodeModules = /[\\/]node_modules[\\/]|_npx/i.test(ROOT);
14
+ export const IS_DEV_REPO = !IS_PACKAGED && !insideNodeModules && fs.existsSync(path.join(ROOT, '.git'));
15
+
16
+ // Постоянный каталог данных терминала:
17
+ // • при запуске через npx neuroalpha@latest данные ВСЕГДА живут в ~/.neuroalpha и не пропадают при обновлениях;
18
+ // • при локальной разработке из git-репозитория (если есть ./data/trading.db или .git) сохраняется ./data.
19
+ function resolveDataDir() {
20
+ if (process.env.NEUROALPHA_DATA_DIR) return path.resolve(process.env.NEUROALPHA_DATA_DIR);
21
+ const repoData = path.join(ROOT, 'data');
22
+ if (!IS_PACKAGED && !insideNodeModules && (IS_DEV_REPO || fs.existsSync(path.join(repoData, 'trading.db')))) {
23
+ return repoData;
24
+ }
25
+ return path.join(os.homedir(), '.neuroalpha');
26
+ }
27
+
28
+ export const DATA_DIR = resolveDataDir();
29
+ fs.mkdirSync(DATA_DIR, { recursive: true });
30
+
31
+ if (!IS_PACKAGED) {
32
+ for (const envFile of [path.join(DATA_DIR, '.env'), path.join(ROOT, '.env'), path.resolve(process.cwd(), '.env')]) {
33
+ if (fs.existsSync(envFile)) {
34
+ try { process.loadEnvFile(envFile); } catch { /* ignore */ }
35
+ }
36
+ }
37
+ }
38
+
39
+ export const strategyDefaults = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/strategy.json'), 'utf8'));
40
+
41
+ function resolveApiKey() {
42
+ if (process.env.LLM_API_KEY) return process.env.LLM_API_KEY.trim();
43
+ const file = process.env.LLM_API_KEY_FILE;
44
+ if (!file) return '';
45
+ try {
46
+ const raw = fs.readFileSync(path.resolve(ROOT, file), 'utf8').trim();
47
+ if (raw.startsWith('{')) {
48
+ const j = JSON.parse(raw);
49
+ return String(j.apiKey || j.api_key || j.key || '').trim();
50
+ }
51
+ return raw;
52
+ } catch {
53
+ return '';
54
+ }
55
+ }
56
+
57
+ export const llmConfig = {
58
+ provider: (process.env.LLM_PROVIDER || 'openai').toLowerCase(),
59
+ baseUrl: process.env.LLM_BASE_URL || 'https://models.odant.org/v1/chat/completions',
60
+ model: process.env.LLM_MODEL || 'Qwen/Qwen3.8-27B-FP8',
61
+ apiKey: resolveApiKey(),
62
+ reasoningEffort: process.env.LLM_REASONING_EFFORT || 'low',
63
+ maxTokens: Number(process.env.LLM_MAX_TOKENS || 4000),
64
+ timeoutMs: Number(process.env.LLM_TIMEOUT_MS || 90000),
65
+ };
66
+ llmConfig.enabled = llmConfig.provider !== 'none' && !!llmConfig.baseUrl && !!llmConfig.model && !!llmConfig.apiKey;
67
+
68
+ export const PORT = Number(process.env.PORT || 3000);
69
+ export const LISTEN_HOST = String(process.env.HOST || '').trim() || undefined;
70
+ export const DEFAULT_CLOUD_URL = (
71
+ (IS_PACKAGED ? BUILD.centralUrl : (process.env.PORTAL_URL || process.env.CLOUD_URL))
72
+ || 'https://neuroalpha.odant.org'
73
+ ).replace(/\/+$/, '');
package/server/index.js CHANGED
@@ -1,187 +1,186 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import http from 'node:http';
3
- import os from 'node:os';
4
- import { spawn } from 'node:child_process';
5
- import path from 'node:path';
6
- import express from 'express';
7
- import { WebSocketServer } from 'ws';
8
- import { ROOT, PORT } from './config.js';
9
- import { ROLE, IS_INSTANCE, IS_DESKTOP, IS_CENTRAL, INSTANCE_KIND, LISTEN_HOST } from './role.js';
10
- import { startInstanceAgent, setInstanceCommandHandler } from './instance/agent.js';
11
- import { getIdentity, isActivated } from './instance/identity.js';
12
- import { startCentralReconciler } from './instance/central.js';
13
- import { DEFAULT_CLOUD_URL } from './config.js';
14
- import { activeModelInfo } from './llm/client.js';
15
- import { markStaleRuns, removeLegacyData, removeOrphanEvents, ensureSeedRun } from './db.js';
16
- import { createRouter } from './api/routes.js';
17
- import { authMiddleware, activateLocalLicense, userFromRequest, runWithUser, getUserById } from './auth/index.js';
18
- import { securityHeaders, setAppCsp, isCrossOrigin } from './security.js';
19
- import { live } from './model/live.js';
20
- import { runningResearch, runningStability } from './model/research.js';
21
- import { startNews } from './news/index.js';
22
- import { startNotifyPolling } from './notify/index.js';
23
- import { startBrokerScheduler, brokersState, executeBrokerOrders, rejectBrokerOrders } from './brokers/index.js';
24
- import { chatState, askFromMessenger } from './strategist/index.js';
25
- import { hub } from './hub.js';
26
-
27
- markStaleRuns();
28
- removeLegacyData();
29
- removeOrphanEvents();
30
- ensureSeedRun();
31
-
32
- const app = express();
33
- app.disable('x-powered-by');
34
- app.use(express.json({ limit: '1mb' }));
35
- app.use(securityHeaders);
36
- app.use(authMiddleware);
37
- const server = http.createServer(app);
38
- const wss = new WebSocketServer({ server, path: '/ws' });
39
-
40
- hub.setSender((msg, { uid, global }) => {
41
- const data = JSON.stringify(msg);
42
- for (const c of wss.clients) {
43
- if (c.readyState !== 1) continue;
44
- if (global || c.uid === uid) c.send(data);
45
- }
46
- });
47
-
48
- wss.on('connection', (ws, req) => {
49
- if (isCrossOrigin(req)) {
50
- ws.close(4403, 'forbidden origin');
51
- return;
52
- }
53
- const user = userFromRequest(req);
54
- if (!user) {
55
- ws.close(4401, 'unauthorized');
56
- return;
57
- }
58
- ws.uid = user.id;
59
- runWithUser(user, () => {
60
- ws.send(JSON.stringify({ type: 'hello', live: live.status(), chat: chatState(), running: runningResearch(), stabilityRunning: runningStability(), llm: activeModelInfo() }));
61
- });
62
- });
63
-
64
- // Команды из Telegram / MAX, пришедшие с центра: подтверждение пакета заявок или вопрос к Стратегу
65
- setInstanceCommandHandler((cmd) => runWithUser(getUserById(1), async () => {
66
- if (cmd.type === 'chat' && cmd.text) {
67
- const res = await askFromMessenger(cmd.text, { channel: cmd.channel || 'tg' });
68
- return `🤖 Стратег:\n\n${String(res?.text || 'Готово.').slice(0, 3400)}`;
69
- }
70
- if (cmd.type !== 'batch') return null;
71
- const acc = brokersState().accounts.find((a) => a.id === cmd.accountId);
72
- if (!acc) return 'Команда из мессенджера: счёт не найден.';
73
- const pb = acc.pendingBatch;
74
- if (!pb || pb.status !== 'pending' || (cmd.batchId && pb.id !== cmd.batchId)) {
75
- return `Счёт «${acc.name}»: этот пакет заявок уже неактуален (исполнен, отклонён или заменён новым) — команда не выполнена.`;
76
- }
77
- if (cmd.action === 'exec') {
78
- const st = await executeBrokerOrders(acc.id);
79
- const res = st.accounts.find((a) => a.id === acc.id)?.pendingBatch?.results || [];
80
- const ok = res.filter((r) => r.ok).length;
81
- return `✓ Счёт «${acc.name}»: отправлено брокеру ${ok} из ${res.length} заявок.${ok < res.length ? ` Ошибки: ${res.filter((r) => !r.ok).map((r) => `${r.symbol} — ${r.error}`).join('; ').slice(0, 600)}` : ''}`;
82
- }
83
- if (cmd.action === 'rej') {
84
- rejectBrokerOrders(acc.id);
85
- return `✕ Счёт «${acc.name}»: пакет заявок отклонён.`;
86
- }
87
- return null;
88
- }));
89
- startInstanceAgent();
90
- if (IS_CENTRAL) startCentralReconciler({ centralUrl: String(process.env.CENTRAL_PUBLIC_URL || DEFAULT_CLOUD_URL).replace(/\/+$/, '') });
91
- live.resume();
92
- startNews();
93
- startNotifyPolling();
94
- startBrokerScheduler();
95
-
96
- if (process.env.NEUROALPHA_API_KEY && !IS_INSTANCE) {
97
- activateLocalLicense(process.env.NEUROALPHA_API_KEY, process.env.CLOUD_URL || '')
98
- .then((lic) => console.log(`✓ Активирован API-ключ: тариф «${lic.planName}» (${lic.email})`))
99
- .catch((e) => console.warn(`⚠ Проверка API-ключа: ${e.message}`));
100
- }
101
-
102
- // Проверка живости для Kubernetes (liveness/readiness), без данных
103
- app.get('/healthz', (req, res) => res.json({ ok: true, role: ROLE }));
104
-
105
- // Машина клиента: витрина, оферта, админка и вход живут в центре; без сессии — вход через кабинет центра
106
- if (IS_INSTANCE) {
107
- const central = () => getIdentity().centralUrl;
108
- const toCentral = (p) => (req, res) => res.redirect(`${central()}${p}`);
109
- app.get(['/landing', '/landing.html'], toCentral('/landing'));
110
- app.get(['/legal', '/legal.html'], toCentral('/legal'));
111
- app.get(['/admin', '/admin.html'], toCentral('/landing'));
112
- app.get(['/', '/index.html'], (req, res, next) => {
113
- // установка на компьютер до активации — экран ввода кода из личного кабинета
114
- if (IS_DESKTOP && !isActivated()) {
115
- setAppCsp(res);
116
- return res.sendFile(path.join(ROOT, 'public', 'activate.html'));
117
- }
118
- if (!req.user) return res.redirect(`${central()}/api/instances/open?i=${encodeURIComponent(getIdentity().id)}`);
119
- setAppCsp(res);
120
- next();
121
- });
122
- }
123
-
124
- app.get('/landing', (req, res) => res.sendFile(path.join(ROOT, 'public', 'landing.html')));
125
- app.get('/legal', (req, res) => res.sendFile(path.join(ROOT, 'public', 'legal.html')));
126
- app.get('/admin', (req, res) => {
127
- if (!req.user || req.user.role !== 'admin') return res.redirect('/landing');
128
- return res.sendFile(path.join(ROOT, 'public', 'admin.html'));
129
- });
130
- app.get('/', (req, res, next) => {
131
- // На центральном сервере (IS_CENTRAL) у клиентов на главном домене работает витрина и Личный кабинет (с биллингом и переходом на их PaaS-машину),
132
- // а локальный торговый терминал самого центра открывается только для администратора (или в dev/instance режиме)
133
- if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1') || (IS_CENTRAL && (req.user.role !== 'admin' || req.query.payment_check === '1') && req.query.terminal !== '1')) {
134
- return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
135
- }
136
- setAppCsp(res);
137
- next();
138
- });
139
- app.use('/api', createRouter());
140
- app.use('/docs', express.static(path.join(ROOT, 'docs')));
141
- app.use(express.static(path.join(ROOT, 'public')));
142
-
143
- function lanUrls() {
144
- return Object.values(os.networkInterfaces()).flat()
145
- .filter((a) => a && a.family === 'IPv4' && !a.internal)
146
- .map((a) => `http://${a.address}:${PORT}`);
147
- }
148
-
149
- const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
150
- || (process.env.OPEN_BROWSER !== '0'
151
- && process.env.NO_BROWSER !== '1'
152
- && !process.argv.includes('--no-browser')
153
- && !process.execArgv.includes('--watch')
154
- && !IS_CENTRAL
155
- && (!IS_INSTANCE || IS_DESKTOP)
156
- && (process.platform === 'win32' || process.platform === 'darwin' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)));
157
-
158
- wss.on('error', () => {});
159
- server.on('error', (e) => {
160
- if (e.code === 'EADDRINUSE') {
161
- console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: http://localhost:${PORT}`);
162
- if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
163
- } else {
164
- console.error(e);
165
- }
166
- process.exit(1);
167
- });
168
-
169
- server.listen(PORT, LISTEN_HOST, () => {
170
- const ident = IS_INSTANCE ? getIdentity() : null;
171
- console.log(`Консорциум агентов запущен · режим ${ROLE}${IS_INSTANCE ? ` (${INSTANCE_KIND}) · машина ${ident.id || 'не активирована'} · центр ${ident.centralUrl}` : ''}`);
172
- const llm = activeModelInfo();
173
- console.log(`ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (нет модели или ключа: Настройки → Модели ИИ)'}`);
174
- if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
175
- console.log(`Клиент: http://localhost:${PORT}`);
176
- if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
177
- });
178
-
179
- function openBrowser(url) {
180
- const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
181
- : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
182
- spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true }).on('error', () => {}).unref();
183
- }
184
-
185
- const shutdown = () => { live.suspend().finally(() => server.close(() => process.exit(0))); setTimeout(() => process.exit(0), 2000); };
186
- process.on('SIGINT', shutdown);
187
- process.on('SIGTERM', shutdown);
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import http from 'node:http';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import { spawn } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import express from 'express';
8
+ import { WebSocketServer } from 'ws';
9
+ import { ROOT, DATA_DIR, PORT, LISTEN_HOST, DEFAULT_CLOUD_URL } from './config.js';
10
+ import { activeModelInfo } from './llm/client.js';
11
+ import { registerLocalLlamaProvider } from './llm/providers.js';
12
+ import { setLocalModelReadyHook, resumeSavedLocalEngineOnStartup, getLocalEngineQuickStatus } from './llm/local-engine.js';
13
+ import { markStaleRuns, removeLegacyData, removeOrphanEvents, ensureSeedRun } from './db.js';
14
+ import { createRouter } from './api/routes.js';
15
+ import {
16
+ authMiddleware, activateLocalLicense, userFromRequest, runWithUser,
17
+ getTerminalLicenseState, startAutonomousLicenseWatcher,
18
+ } from './auth/index.js';
19
+ import { securityHeaders, setAppCsp, isCrossOrigin } from './security.js';
20
+ import { live } from './model/live.js';
21
+ import { runningResearch, runningStability } from './model/research.js';
22
+ import { startNews } from './news/index.js';
23
+ import { startNotifyPolling } from './notify/index.js';
24
+ import { startBrokerScheduler } from './brokers/index.js';
25
+ import { chatState } from './strategist/index.js';
26
+ import { hub } from './hub.js';
27
+
28
+ markStaleRuns();
29
+ removeLegacyData();
30
+ removeOrphanEvents();
31
+ ensureSeedRun();
32
+
33
+ setLocalModelReadyHook((info) => {
34
+ registerLocalLlamaProvider(info);
35
+ hub.emit({ type: 'llm:changed', llm: activeModelInfo() });
36
+ hub.emit({ type: 'chat:state', state: chatState() });
37
+ });
38
+
39
+ const app = express();
40
+ app.disable('x-powered-by');
41
+ app.use(express.json({ limit: '1mb' }));
42
+ app.use(securityHeaders);
43
+ app.use(authMiddleware);
44
+ const server = http.createServer(app);
45
+ const wss = new WebSocketServer({ server, path: '/ws' });
46
+
47
+ hub.setSender((msg, { uid, global }) => {
48
+ const data = JSON.stringify(msg);
49
+ for (const c of wss.clients) {
50
+ if (c.readyState !== 1) continue;
51
+ if (global || c.uid === uid) c.send(data);
52
+ }
53
+ });
54
+
55
+ wss.on('connection', (ws, req) => {
56
+ if (isCrossOrigin(req)) {
57
+ ws.close(4403, 'forbidden origin');
58
+ return;
59
+ }
60
+ const user = userFromRequest(req);
61
+ if (!user) {
62
+ ws.close(4401, 'unauthorized');
63
+ return;
64
+ }
65
+ ws.uid = user.id;
66
+ runWithUser(user, () => {
67
+ ws.send(JSON.stringify({
68
+ type: 'hello',
69
+ live: live.status(),
70
+ chat: chatState(),
71
+ running: runningResearch(),
72
+ stabilityRunning: runningStability(),
73
+ llm: activeModelInfo(),
74
+ localLlm: getLocalEngineQuickStatus(),
75
+ }));
76
+ });
77
+ });
78
+
79
+ startAutonomousLicenseWatcher();
80
+ resumeSavedLocalEngineOnStartup();
81
+ live.resume();
82
+ startNews();
83
+ startNotifyPolling();
84
+ startBrokerScheduler();
85
+
86
+ const cliToken = process.env.NEUROALPHA_TOKEN || process.env.NEUROALPHA_API_KEY || '';
87
+ if (cliToken && cliToken.startsWith('na_tok_')) {
88
+ activateLocalLicense(cliToken, process.env.PORTAL_URL || process.env.CLOUD_URL || '')
89
+ .then((lic) => console.log(` ✓ Активирована лицензия: тариф «${lic.planName}» (${lic.email})`))
90
+ .catch((e) => console.warn(` ⚠ Активация токена: ${e.message}`));
91
+ }
92
+
93
+ app.get('/healthz', (req, res) => res.json({ ok: true, app: 'neuroalpha' }));
94
+
95
+ app.get(['/legal', '/legal.html'], (req, res) => {
96
+ const portalUrl = getTerminalLicenseState().portalUrl || DEFAULT_CLOUD_URL;
97
+ res.redirect(`${portalUrl}/legal`);
98
+ });
99
+
100
+ // Если терминал ещё не активирован токеном из ЛК ИЛИ открыт по внешней сети/на VPS без сессии — показываем экран активации / входа
101
+ app.get(['/', '/index.html'], (req, res, next) => {
102
+ if (!req.user) {
103
+ setAppCsp(res);
104
+ return res.sendFile(path.join(ROOT, 'public', 'activate.html'));
105
+ }
106
+ setAppCsp(res);
107
+ return next();
108
+ });
109
+
110
+ app.use('/api', createRouter());
111
+ app.use('/docs', express.static(path.join(ROOT, 'docs')));
112
+ app.use(express.static(path.join(ROOT, 'public')));
113
+
114
+ function openBrowser(url) {
115
+ try {
116
+ if (process.platform === 'win32') {
117
+ spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true }).on('error', () => {}).unref();
118
+ } else if (process.platform === 'darwin') {
119
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
120
+ } else if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
121
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
122
+ }
123
+ } catch { /* ignore */ }
124
+ }
125
+
126
+ function lanUrls() {
127
+ return Object.entries(os.networkInterfaces())
128
+ .filter(([name]) => !/vethernet|wsl|docker|hyper-v|virtualbox|vmware/i.test(name))
129
+ .flatMap(([, list]) => list || [])
130
+ .filter((a) => a && a.family === 'IPv4' && !a.internal)
131
+ .map((a) => `http://${a.address}:${PORT}`);
132
+ }
133
+
134
+ function fmtClickableUrl(url, out = process.stdout) {
135
+ if (!out?.isTTY) return url;
136
+ const osc8 = Boolean(
137
+ process.env.WT_SESSION
138
+ || process.env.TERM_PROGRAM
139
+ || process.env.COLORTERM
140
+ || process.env.FORCE_HYPERLINK === '1'
141
+ || (process.platform !== 'win32' && process.env.TERM && process.env.TERM !== 'dumb'),
142
+ );
143
+ const styled = `\x1b[4;96m${url}\x1b[0m`;
144
+ return osc8 ? `\x1b]8;;${url}\x1b\\${styled}\x1b]8;;\x1b\\` : styled;
145
+ }
146
+
147
+ const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
148
+ || (process.env.OPEN_BROWSER !== '0'
149
+ && process.env.NO_BROWSER !== '1'
150
+ && !process.argv.includes('--no-browser')
151
+ && !process.execArgv.includes('--watch')
152
+ && (process.platform === 'win32' || process.platform === 'darwin' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)));
153
+
154
+ wss.on('error', () => {});
155
+ server.on('error', (e) => {
156
+ const clientUrl = `http://localhost:${PORT}`;
157
+ if (e.code === 'EADDRINUSE') {
158
+ console.error(`Порт ${PORT} занят: терминал уже запущен? Клиент: ${fmtClickableUrl(clientUrl, process.stderr)}`);
159
+ if (shouldOpenBrowser) openBrowser(clientUrl);
160
+ } else {
161
+ console.error(e);
162
+ }
163
+ process.exit(1);
164
+ });
165
+
166
+ server.listen(PORT, LISTEN_HOST, () => {
167
+ let ver = '';
168
+ try { ver = ` v${JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version}`; } catch { /* ignore */ }
169
+ const lic = getTerminalLicenseState();
170
+ const licInfo = lic.activated
171
+ ? (lic.devBypass ? 'режим разработчика' : `${lic.planName}${lic.perpetual ? ' (бессрочно)' : ''}`)
172
+ : 'ожидает активации токеном ЛК';
173
+ console.log(`◈ НейроАльфа Терминал${ver} запущен · ${licInfo}`);
174
+ console.log(` Данные: ${DATA_DIR}`);
175
+ const llm = activeModelInfo();
176
+ console.log(` ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (Настройки → Модели ИИ)'}`);
177
+ if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В сети: ${fmtClickableUrl(url)}`);
178
+ const clientUrl = `http://localhost:${PORT}`;
179
+ const enterHint = process.stdin?.isTTY ? ' (Enter — открыть в браузере)' : '';
180
+ console.log(` Клиент: ${fmtClickableUrl(clientUrl)}${enterHint}`);
181
+ if (shouldOpenBrowser) openBrowser(clientUrl);
182
+ if (process.stdin?.isTTY) {
183
+ process.stdin.on('data', () => openBrowser(clientUrl));
184
+ process.stdin.unref?.();
185
+ }
186
+ });