neuroalpha 0.3.17 → 0.5.2

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 (39) hide show
  1. package/config/certs/russian_trusted_root_ca.pem +33 -0
  2. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -4
  4. package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +2 -2
  5. package/package.json +60 -53
  6. package/public/activate.html +36 -0
  7. package/public/activate.js +33 -0
  8. package/public/admin.html +126 -7
  9. package/public/app.js +589 -66
  10. package/public/index.html +48 -21
  11. package/public/landing.html +106 -37
  12. package/public/legal.html +4 -4
  13. package/public/styles.css +152 -36
  14. package/server/api/routes.js +308 -37
  15. package/server/auth/index.js +79 -15
  16. package/server/brokers/index.js +1095 -168
  17. package/server/build-info.js +7 -0
  18. package/server/config.js +13 -6
  19. package/server/data/moex.js +6 -4
  20. package/server/db.js +22 -18
  21. package/server/desktop-entry.js +103 -0
  22. package/server/hub.js +28 -14
  23. package/server/index.js +82 -12
  24. package/server/instance/agent.js +259 -0
  25. package/server/instance/central.js +491 -0
  26. package/server/instance/identity.js +79 -0
  27. package/server/instance/provision.js +166 -0
  28. package/server/instance/signing.js +71 -0
  29. package/server/model/edge.js +19 -0
  30. package/server/model/forecast.js +107 -57
  31. package/server/model/live.js +1 -1
  32. package/server/model/models.js +12 -3
  33. package/server/model/research.js +218 -30
  34. package/server/notify/index.js +51 -16
  35. package/server/role.js +44 -0
  36. package/server/security.js +40 -0
  37. package/server/strategist/index.js +86 -47
  38. package/server/strategist/tools.js +231 -42
  39. package/server/tls-ca.js +32 -0
@@ -0,0 +1,40 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Защитные HTTP-заголовки, CSP для терминала и защита от межсайтовых запросов (CSRF / Cross-Site WebSocket)
3
+
4
+ const APP_CSP = [
5
+ "default-src 'self'",
6
+ "script-src 'self' https://unpkg.com",
7
+ "style-src 'self' 'unsafe-inline'",
8
+ "img-src 'self' data: https:",
9
+ "font-src 'self' data:",
10
+ "connect-src 'self' ws: wss:",
11
+ "object-src 'none'",
12
+ "base-uri 'self'",
13
+ "form-action 'self'",
14
+ "frame-ancestors 'none'",
15
+ ].join('; ');
16
+
17
+ export function securityHeaders(req, res, next) {
18
+ res.setHeader('X-Content-Type-Options', 'nosniff');
19
+ res.setHeader('Referrer-Policy', 'same-origin');
20
+ res.setHeader('X-Frame-Options', 'DENY');
21
+ if (req.path === '/index.html') res.setHeader('Content-Security-Policy', APP_CSP);
22
+ next();
23
+ }
24
+
25
+ // CSP торгового терминала (index.html). На лендинге и в админке есть встроенные скрипты — им CSP не ставится.
26
+ export function setAppCsp(res) {
27
+ res.setHeader('Content-Security-Policy', APP_CSP);
28
+ }
29
+
30
+ // Запрос из браузера с чужого сайта: Origin есть и не совпадает с Host.
31
+ // Запросы без Origin (curl, боты, серверные клиенты API) не браузерные — их защищает авторизация.
32
+ export function isCrossOrigin(req) {
33
+ const origin = req.headers?.origin;
34
+ if (!origin || origin === 'null') return Boolean(origin);
35
+ try {
36
+ return new URL(origin).host.toLowerCase() !== String(req.headers?.host || '').toLowerCase();
37
+ } catch {
38
+ return true;
39
+ }
40
+ }
@@ -6,7 +6,7 @@ import { getSettings } from '../settings.js';
6
6
  import { brokersState } from '../brokers/index.js';
7
7
  import {
8
8
  currentConversation, newConversation, addMessage, updateMessage, getMessage, getMessages,
9
- kvGet, kvSet,
9
+ kvGet, kvSet, currentUid,
10
10
  } from '../db.js';
11
11
  import { TOOLS, MODES, toolSchemas, OOS_LIMIT } from './tools.js';
12
12
 
@@ -15,30 +15,40 @@ const MSK = 3 * 3600000;
15
15
  const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
16
16
  const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
17
17
 
18
- const state = {
19
- conv: null,
20
- run: null, // { ctrl, mode }
21
- waiting: null, // { msgId, callId }
22
- budgetLeft: 0,
23
- };
18
+ // Состояние чата отдельно для каждого пользователя (uid берётся из контекста запроса)
19
+ const states = new Map();
20
+ function userState() {
21
+ const uid = currentUid();
22
+ let s = states.get(uid);
23
+ if (!s) {
24
+ s = {
25
+ conv: null,
26
+ run: null, // { ctrl, mode }
27
+ waiting: null, // { msgId, callId }
28
+ budgetLeft: 0,
29
+ };
30
+ states.set(uid, s);
31
+ }
32
+ return s;
33
+ }
24
34
 
25
35
  export const getMode = () => 'advisor';
26
36
  export const getBudget = () => 8;
27
37
 
28
38
  function conv() {
29
- if (!state.conv) state.conv = currentConversation();
30
- return state.conv;
39
+ if (!userState().conv) userState().conv = currentConversation();
40
+ return userState().conv;
31
41
  }
32
42
 
33
43
  export function chatState() {
34
44
  const llm = activeModelInfo();
35
45
  return {
36
46
  convId: conv().id,
37
- busy: !!state.run,
38
- waiting: state.waiting,
47
+ busy: !!userState().run,
48
+ waiting: userState().waiting,
39
49
  mode: 'advisor',
40
50
  budget: 8,
41
- budgetLeft: state.budgetLeft,
51
+ budgetLeft: userState().budgetLeft,
42
52
  llm: llm.enabled,
43
53
  tools: llm.tools,
44
54
  oosUsed: 0,
@@ -63,26 +73,53 @@ function systemPrompt() {
63
73
  } catch { /* ignore */ }
64
74
 
65
75
  const noTools = !activeModelInfo().tools;
76
+ const nowTs = Date.now();
77
+ const nowMsk = new Date(nowTs + MSK);
78
+ const DAYS_RU = ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота'];
79
+ const todayIso = nowMsk.toISOString().slice(0, 10);
80
+ const yesterdayIso = new Date(nowTs + MSK - 86400000).toISOString().slice(0, 10);
81
+ const fmtRuDate = (iso) => iso.split('-').reverse().join('.');
82
+ const nowClock = nowMsk.toISOString().slice(11, 16);
83
+ const dow = nowMsk.getUTCDay();
84
+ const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
85
+ const moexStatus = (dow === 0 || dow === 6)
86
+ ? 'выходной день (основные торги закрыты)'
87
+ : (mins >= 9 * 60 + 50 && mins <= 18 * 60 + 50)
88
+ ? 'идёт основная торговая сессия Мосбиржи (09:50–18:50 МСК)'
89
+ : (mins > 18 * 60 + 50 && mins <= 23 * 60 + 50)
90
+ ? 'идёт вечерняя торговая сессия Мосбиржи (19:00–23:50 МСК)'
91
+ : 'межсессионный перерыв / торги ещё не открылись';
92
+
66
93
  const modeText = noTools
67
94
  ? 'Выбранная модель ИИ не поддерживает вызов инструментов — ты отвечаешь и консультируешь по данным из контекста ниже.'
68
- : 'Ты можешь свободно вызывать читающие инструменты (`get_brokers_status`, `model_forecast`, `get_news`, `market_neighbors`, `get_settings`, `broker_onboarding_guide`, `discover_broker_accounts`, `analyze_run`), а действия `connect_broker_account` и `set_settings` предлагаешь с подтверждением пользователя кнопкой.';
95
+ : 'Ты можешь свободно вызывать читающие инструменты (`get_brokers_status`, `get_broker_operations`, `model_forecast`, `get_news`, `market_neighbors`, `get_settings`, `broker_onboarding_guide`, `discover_broker_accounts`, `analyze_run`), а действия `connect_broker_account` и `set_settings` предлагаешь с подтверждением пользователя кнопкой.';
69
96
 
70
97
  return `Ты — «Стратег», персональный ИИ-консультант и помощник пользователя в облачной платформе алгоритмической торговли «НейроАльфа» (Московская биржа, MOEX).
71
- Твоя задача — простым, понятным русским языком объяснять пользователю прогнозы и сигналы модели, разбирать состояние его брокерских счетов и портфеля, объяснять новости рынка и помогать с подключением и настройкой счетов.
72
- ВАЖНО: Ты НЕ ставишь эксперименты и НЕ подбираешь параметры математического ядра — ядро платформы уже полностью откалибровано и работает в эталонном автоматическом режиме (>400% годовых на истории).
98
+ Твоя задача — простым, понятным русским языком объяснять пользователю прогнозы и сигналы модели, разбирать состояние его брокерских счетов, портфеля и историю сделок, объяснять новости рынка и помогать с подключением и настройкой счетов.
99
+ ВАЖНО: Ты НЕ ставишь эксперименты и НЕ подбираешь параметры математического ядра — оно работает в зафиксированном автоматическом режиме.
100
+ ЗАПРЕЩЕНО обещать или прогнозировать доходность («X% годовых», «гарантированно», «без риска»), называть цифры бэктеста ожидаемым доходом и давать индивидуальные инвестиционные рекомендации. Если спрашивают про доходность — объясни честно: на истории (бэктест с реальными издержками ~0,1% на сторону, задержкой исполнения и ограничением по ликвидности) стратегия давала порядка десятков процентов годовых при просадках до ~30%, но прошлые результаты не гарантируют будущих, возможны убытки, а при росте издержек выше ~0,17% на сторону стратегия убыточна.
101
+
102
+ ## Текущее время и календарь Мосбиржи (МСК, UTC+3)
103
+ - **Сейчас:** ${DAYS_RU[dow]}, **${fmtRuDate(todayIso)}** (${todayIso}), **${nowClock} МСК** — ${moexStatus}.
104
+ - **Сегодняшняя дата:** ${fmtRuDate(todayIso)} (${todayIso}).
105
+ - **Вчерашняя дата:** ${fmtRuDate(yesterdayIso)} (${yesterdayIso}).
106
+ - **ВАЖНОЕ ПРАВИЛО ПО ВРЕМЕНИ И ТОРГАМ:**
107
+ - Инструмент \`get_brokers_status\` показывает **только текущий снимок портфеля прямо сейчас** (какие бумаги лежат на счёте в эту минуту и какие заявки висят в стакане сейчас). Никогда не выдавай текущий портфель из \`get_brokers_status\` за «итоги вчерашних торгов»!
108
+ - Если пользователь спрашивает **«как прошли вчерашние торги»**, **«какие сделки были вчера / сегодня / на неделе»** или просит отчёт по прошлым сделкам — **ОБЯЗАТЕЛЬНО вызывай инструмент \`get_broker_operations\`** (например \`{"period": "yesterday"}\` для вчерашнего дня или \`{"period": "today"}\` для сегодняшнего дня)!
73
109
 
74
110
  ## Как устроено автоматическое ядро платформы «НейроАльфа» (Рынок как язык)
75
111
  - **Словарь:** ~100 самых ликвидных акций индекса широкого рынка Мосбиржи (MOEXBMI) + индекс IMOEX. Свечи 10-минутные, дивидендные отсечки скорректированы со сверкой по реальному гэпу цены, объёмы нормированы по 24-часовому суточному профилю (valRatio по часам МСК).
76
112
  - **Карта рынка и авто-эмбеддинг (modelEmbedDim = 0 Авто):** окно обучения фиксировано на 90 торговых днях. На каждом шаге размерность карты рынка автоматически определяется по спектральному порогу Марченко–Пастура (отсечение рыночного шума от истинных факторов).
77
113
  - **12 голов внимания и мульти-горизонт (1,5–3,5 часа):** модель смотрит на 3 масштаба времени (1ч, 3ч, день), цену и денежный поток, узких и широких соседей по карте, предсказывая чистое остаточное движение каждой акции относительно индекса Мосбиржи.
78
114
  - **Пороги входа и удержания:**
79
- - Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания). При стандартных издержках 0,05% за сделку это +0,25%.
80
- - Авто-балансировка по тренду индекса: когда индекс Мосбиржи выше 20-дневной средней (растущий рынок), покупки открываются от +0,25%, а контртрендовые шорты ужесточаются в 1,25× (до −0,313%). На падающем рынке — наоборот (шорты облегчаются, покупки ужесточаются).
81
- - Удержание позиции: первые 65 минут после входа действует мягкий порог удержания (0,35× от базового, ~0,09%), чтобы сделку не выбило случайным шумом, а затем включается плотный порог фиксации прибыли (0,95×, ~0,238%).
115
+ - Порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 2 (запас над реальными издержками по стресс-тесту и фактическим сделкам). При издержках 0,05% за сделку это +0,50%.
116
+ - Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от +0,50%, а контртрендовые шорты ужесточаются в 1,25×. На падающем рынке — наоборот.
117
+ - Удержание позиции: первые 65 минут после входа действует мягкий порог удержания (0,35× от порога входа), затем — плотный порог фиксации прибыли.
118
+ - Реальные издержки на счёте Т-Банка (по фактическим сделкам): комиссия ~0,04% + отклонение цены исполнения от цены сигнала ~0,03–0,08% на сторону.
82
119
  - **Dynamic Top-K (modelTopK = 0 Авто) и Conviction Sizing:** фиксированного числа акций нет — робот держит от 0 до 4 лонгов и до 4 шортов в зависимости от силы сигналов и концентрирует слот в лучших лидерах.
83
120
 
84
121
  ## Брокерские счета и режимы работы
85
- Платформа поддерживает 9 российских брокеров: Т-Банк (T-Invest API, включая секцию TQBR и SPBRU для SPBE), Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ и Альфа-Инвестиции.
122
+ Полная поддержка — Т-Банк (T-Invest API, включая секцию TQBR и SPBRU для SPBE). Финам, БКС, Алор, Цифра брокер и ITI Capital — бета, только «Подтверждение заявок». СберИнвестиции, ВТБ и Альфа-Инвестиции сейчас не подключаются (нет открытого API для заявок).
86
123
  - **Режимы работы счёта (mode):**
87
124
  1. readonly («Только чтение») — безопасный мониторинг портфеля и расчёт заявок без отправки в стакан.
88
125
  2. confirm («Подтверждение заявок», рекомендуется) — робот считает лимитные заявки в целых лотах и присылает их в веб-терминал и в Telegram / MAX на подтверждение одной кнопкой.
@@ -94,10 +131,11 @@ function systemPrompt() {
94
131
  3. takeover («Полное управление») — сразу продать все старые нецелевые акции из словаря стратегии (облигации, фонды и бумаги вне словаря не трогаются никогда), освободив весь кэш под стратегию.
95
132
 
96
133
  ## Тарифные планы SaaS
97
- - **«Инвестор» (3 900 ₽/мес):** задавленная доходность до 60% годовых, только покупки (без шорта), 1 брокерский счёт со стартовым лимитом до 1 000 000 ₽ (+ 100% реинвестирование всей прибыли).
98
- - **«Профи» (9 900 ₽/мес):** задавленная доходность до 120% годовых, покупки и шорты, до 5 брокерских счетов со стартовым лимитом до 1 000 000 ₽ на каждом (+ 100% реинвестирование всей прибыли).
99
- - **«VIP» (по индивидуальному согласованию):** задавленная доходность до 240% годовых, покупки и шорты, количество счетов и начальная сумма по согласованию.
100
- - **Режим «Хозяин» (owner):** без ограничений по доходности (>400% годовых), счетам и капиталу + доступ к разделу «Эксперименты».
134
+ Тарифы различаются возможностями, а не доходностью (доходность тарифом не определяется и не гарантируется):
135
+ - **«Инвестор» (3 900 ₽/мес):** только покупки (без шорта), 1 брокерский счёт со стартовым лимитом до 1 000 000 ₽ (+ реинвестирование прибыли).
136
+ - **«Профи» (9 900 ₽/мес):** покупки и шорты, до 5 брокерских счетов со стартовым лимитом до 1 000 000 ₽ на каждом.
137
+ - **«VIP» (по договору):** покупки и шорты, количество счетов и сумма по договору, облачная машина или установка на свой компьютер.
138
+ - **Режим «Хозяин» (owner):** без ограничений по счетам и капиталу + доступ к разделу «Эксперименты».
101
139
 
102
140
  ## Подключённые брокерские счета пользователя
103
141
  ${brkSummary}
@@ -107,9 +145,10 @@ ${brkSummary}
107
145
 
108
146
  ## Доступные тебе инструменты
109
147
  ${modeText}
110
- - Чтобы разобрать портфель пользователя, свободные рубли или заявки — вызови \`get_brokers_status\`.
148
+ - Чтобы разобрать текущий портфель пользователя прямо сейчас, свободные рубли или активные заявки в стакане — вызови \`get_brokers_status\`.
149
+ - Чтобы посмотреть, как прошли торги вчера, сегодня или на неделе (история исполненных сделок, покупок, продаж, комиссий и оборота по времени МСК) — вызови \`get_broker_operations\` (например \`{"period": "yesterday"}\` или \`{"period": "today"}\`).
111
150
  - Чтобы объяснить текущую ситуацию на рынке — вызови \`model_forecast\` и \`get_news\`.
112
- - Чтобы помочь подключить счёт — используй \`broker_onboarding_guide\`, \`discover_broker_accounts\` (если пользователь дал токен Т-Банка \`t.…\`) и \`connect_broker_account\`.
151
+ - Чтобы помочь подключить счёт — используй \`broker_onboarding_guide\`, \`connect_broker_account\` и \`discover_broker_accounts\` (по id уже сохранённого счёта). НИКОГДА не проси прислать токен брокера в чат: токен пользователь вставляет только в форму счёта («+ Добавить счёт» → поле «Токен»). Если пользователь всё же прислал токен в чат — не повторяй его в ответе, посоветуй перевыпустить токен у брокера и вставить новый в форму. Полностью поддерживается Т-Банк; Финам, БКС, Алор, Цифра и ITI — бета, только с подтверждением заявок; СберИнвестиции, ВТБ и Альфа сейчас не подключаются.
113
152
 
114
153
  ## Как отвечать
115
154
  - Размышляй и пиши по-русски, доброжелательно, чётко и простым языком без лишнего жаргона.
@@ -228,13 +267,13 @@ async function processCalls(msg, mode) {
228
267
  const mObj = MODES[mode] || MODES.advisor;
229
268
  for (const call of msg.tool_calls || []) {
230
269
  if (done.has(call.id)) continue;
231
- if (state.run?.ctrl.signal.aborted) return 'aborted';
270
+ if (userState().run?.ctrl.signal.aborted) return 'aborted';
232
271
  const tool = TOOLS[call.function.name];
233
272
  const st = getMessage(msg.id).data?.tools?.[call.id]?.status;
234
273
  if (tool && mObj.confirm?.(tool) && st !== 'approved') {
235
274
  const args = parseArgs(call) || {};
236
275
  setToolStatus(msg.id, call.id, { status: 'pending', preview: tool.preview ? tool.preview(args) : '' });
237
- state.waiting = { msgId: msg.id, callId: call.id };
276
+ userState().waiting = { msgId: msg.id, callId: call.id };
238
277
  return 'waiting';
239
278
  }
240
279
  await execTool(msg, call, mode);
@@ -243,7 +282,7 @@ async function processCalls(msg, mode) {
243
282
  }
244
283
 
245
284
  async function loop(mode = 'advisor') {
246
- const ctrl = state.run.ctrl;
285
+ const ctrl = userState().run.ctrl;
247
286
  if (!activeModelInfo().tools) mode = 'discuss';
248
287
  for (let step = 0; step < MAX_STEPS; step++) {
249
288
  if (ctrl.signal.aborted) return;
@@ -297,9 +336,9 @@ async function loop(mode = 'advisor') {
297
336
  }
298
337
 
299
338
  async function runAgent(mode = 'advisor') {
300
- if (state.run) throw new Error('ИИ уже работает');
339
+ if (userState().run) throw new Error('ИИ уже работает');
301
340
  if (!isLlmEnabled()) throw new Error('ИИ не подключён: выберите модель в разделе «Модели ИИ»');
302
- state.run = { ctrl: new AbortController(), mode };
341
+ userState().run = { ctrl: new AbortController(), mode };
303
342
  emitState();
304
343
  try {
305
344
  await loop(mode);
@@ -307,7 +346,7 @@ async function runAgent(mode = 'advisor') {
307
346
  console.error(e);
308
347
  emitMsg(addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
309
348
  } finally {
310
- state.run = null;
349
+ userState().run = null;
311
350
  emitState();
312
351
  }
313
352
  }
@@ -316,8 +355,8 @@ async function runAgent(mode = 'advisor') {
316
355
  export function sendUserMessage(text) {
317
356
  text = String(text || '').trim();
318
357
  if (!text) throw new Error('Пустое сообщение');
319
- if (state.run) throw new Error('ИИ ещё работает — дождитесь или нажмите «Стоп»');
320
- if (state.waiting) rejectPending('Пользователь не стал подтверждать и написал сообщение (см. ниже).');
358
+ if (userState().run) throw new Error('ИИ ещё работает — дождитесь или нажмите «Стоп»');
359
+ if (userState().waiting) rejectPending('Пользователь не стал подтверждать и написал сообщение (см. ниже).');
321
360
  const m = addMessage(conv().id, { role: 'user', kind: 'user', content: text });
322
361
  emitMsg(m);
323
362
  runAgent('advisor').catch(() => {});
@@ -325,9 +364,9 @@ export function sendUserMessage(text) {
325
364
  }
326
365
 
327
366
  function rejectPending(comment) {
328
- const w = state.waiting;
367
+ const w = userState().waiting;
329
368
  if (!w) return;
330
- state.waiting = null;
369
+ userState().waiting = null;
331
370
  const msg = getMessage(w.msgId);
332
371
  for (const call of msg.tool_calls || []) {
333
372
  const st = msg.data?.tools?.[call.id]?.status;
@@ -339,23 +378,23 @@ function rejectPending(comment) {
339
378
  }
340
379
 
341
380
  export function resolveAction(msgId, callId, decision, comment = '') {
342
- const w = state.waiting;
381
+ const w = userState().waiting;
343
382
  if (!w || w.msgId !== msgId || w.callId !== callId) throw new Error('Это действие уже не ожидает подтверждения');
344
- if (state.run) throw new Error('ИИ ещё работает');
383
+ if (userState().run) throw new Error('ИИ ещё работает');
345
384
  const mode = 'advisor';
346
385
  if (decision === 'approve') {
347
- state.waiting = null;
386
+ userState().waiting = null;
348
387
  setToolStatus(msgId, callId, { status: 'approved' });
349
388
  } else {
350
389
  const msg = getMessage(msgId);
351
- state.waiting = null;
390
+ userState().waiting = null;
352
391
  const call = msg.tool_calls.find((c) => c.id === callId);
353
392
  const tm = addMessage(conv().id, { role: 'tool', kind: 'tool', tool_call_id: callId, content: JSON.stringify({ отклонено: true, комментарий: comment || 'пользователь отклонил без комментария' }), data: { name: call?.function.name, summary: `Отклонено${comment ? `: ${comment}` : ''}`, ok: false } });
354
393
  emitMsg(tm);
355
394
  setToolStatus(msgId, callId, { status: 'rejected' });
356
395
  }
357
396
  if (!isLlmEnabled()) throw new Error('ИИ не подключён');
358
- state.run = { ctrl: new AbortController(), mode };
397
+ userState().run = { ctrl: new AbortController(), mode };
359
398
  emitState();
360
399
  (async () => {
361
400
  try {
@@ -364,17 +403,17 @@ export function resolveAction(msgId, callId, decision, comment = '') {
364
403
  } catch (e) {
365
404
  emitMsg(addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
366
405
  } finally {
367
- state.run = null;
406
+ userState().run = null;
368
407
  emitState();
369
408
  }
370
409
  })();
371
410
  }
372
411
 
373
412
  export function stopAgent() {
374
- if (state.run) {
375
- state.run.ctrl.abort();
413
+ if (userState().run) {
414
+ userState().run.ctrl.abort();
376
415
  }
377
- if (state.waiting) rejectPending('Пользователь остановил ИИ');
416
+ if (userState().waiting) rejectPending('Пользователь остановил ИИ');
378
417
  emitState();
379
418
  }
380
419
 
@@ -385,10 +424,10 @@ export function setMode() {
385
424
 
386
425
  export function resetConversation() {
387
426
  stopAgent();
388
- state.conv = newConversation();
389
- state.waiting = null;
427
+ userState().conv = newConversation();
428
+ userState().waiting = null;
390
429
  hub.emit({ type: 'chat:reset', state: chatState() });
391
- return state.conv;
430
+ return userState().conv;
392
431
  }
393
432
 
394
433
  export function history() {