neuroalpha 0.5.8 → 0.5.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -72,7 +72,9 @@ const md = (s) => {
72
72
  ? `${p1}<span class="sym-link" data-sym="${sym}" title="Открыть досье акции ${sym}"><b>${sym}</b></span>`
73
73
  : m
74
74
  ));
75
- return DOMPurify.sanitize(withSyms, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
75
+ // широкие таблицы листаются внутри своей обёртки, а не растягивают ленту чата
76
+ const withWraps = withSyms.replace(/<table>/g, '<div class="tbl-wrap"><table>').replace(/<\/table>/g, '</table></div>');
77
+ return DOMPurify.sanitize(withWraps, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
76
78
  };
77
79
  const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
78
80
  const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
@@ -382,12 +384,10 @@ function syncUiAvailability() {
382
384
  if ($('#btn-rename-run')) $('#btn-rename-run').hidden = !isDone;
383
385
  if ($('#btn-ask-run')) {
384
386
  $('#btn-ask-run').hidden = !isDone;
385
- $('#btn-ask-run').disabled = !aiReady;
387
+ $('#btn-ask-run').disabled = !state.chat.state?.llm;
386
388
  $('#btn-ask-run').title = !state.chat.state?.llm
387
389
  ? 'ИИ не подключён (Настройки → Модели ИИ)'
388
- : aiBusy
389
- ? 'Дождитесь завершения текущего ответа Стратега'
390
- : 'Попросить Стратега разобрать этот эксперимент';
390
+ : 'Начать новый диалог со Стратегом по этому эксперименту';
391
391
  }
392
392
  if ($('#btn-del')) $('#btn-del').hidden = !r || isRunning;
393
393
  syncApplyBtn();
@@ -404,12 +404,12 @@ function syncUiAvailability() {
404
404
  $('#btn-refresh-forecast').textContent = forecastBusy ? 'Обновляем…' : '↻ Обновить';
405
405
  }
406
406
  if ($('#btn-ask-forecast')) {
407
- $('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
407
+ $('#btn-ask-forecast').disabled = !state.chat.state?.llm || !state.forecast;
408
408
  $('#btn-ask-forecast').title = !state.forecast
409
409
  ? 'Сначала дождитесь расчёта прогноза'
410
- : !aiReady
411
- ? 'Стратег сейчас занят или ИИ не подключён'
412
- : 'Попросить Стратега разобрать текущий прогноз';
410
+ : !state.chat.state?.llm
411
+ ? 'ИИ не подключён (Настройки → Модели ИИ)'
412
+ : 'Начать новый диалог со Стратегом по текущему прогнозу';
413
413
  }
414
414
  document.querySelectorAll('[data-live-ask]').forEach((b) => {
415
415
  b.disabled = !aiReady;
@@ -866,7 +866,10 @@ $('#run-bar').addEventListener('click', async (e) => {
866
866
  return;
867
867
  }
868
868
  if (e.target.closest('#btn-ask-run')) {
869
- sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
869
+ sendChat(
870
+ `Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`,
871
+ { newSession: true, title: `Разбор исследования №${r.id}` },
872
+ );
870
873
  }
871
874
  });
872
875
  $('#btn-refresh-forecast')?.addEventListener('click', () => {
@@ -874,7 +877,10 @@ $('#btn-refresh-forecast')?.addEventListener('click', () => {
874
877
  loadForecast(false, false);
875
878
  });
876
879
  $('#btn-ask-forecast').addEventListener('click', () => {
877
- sendChat('Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?');
880
+ sendChat(
881
+ 'Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?',
882
+ { newSession: true, title: 'Прогноз и новости рынка' },
883
+ );
878
884
  });
879
885
 
880
886
  function setProgress(pct, text) {
@@ -1115,18 +1121,58 @@ const TOOL_LABELS = {
1115
1121
  connect_broker_account: 'Подключение брокерского счёта',
1116
1122
  };
1117
1123
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1124
+ // Автопрокрутка ленты чата («прилипание к низу»):
1125
+ // • пока пользователь внизу, новые сообщения и потоковый ответ прокручивают ленту вниз;
1126
+ // • как только пользователь сам прокрутил вверх (колесо, тач, клавиши, полоса прокрутки) — лента его не дёргает;
1127
+ // • прилипание возвращается, когда пользователь сам дошёл до низа, нажал «↓» или отправил новый запрос.
1118
1128
  const feed = $('#feed');
1119
- const nearBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
1120
- const scrollDown = (force) => { if (force || nearBottom()) requestAnimationFrame(() => { feed.scrollTop = feed.scrollHeight; }); };
1121
1129
  const feedDown = $('#feed-down');
1130
+ const BOTTOM_EPS = 24;
1131
+ const distFromBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight;
1132
+ let stickToBottom = true;
1133
+ let lastFeedTop = 0;
1134
+ let feedScrollRaf = 0;
1122
1135
  let unseen = 0;
1123
1136
  const updateDown = () => {
1124
- feedDown.hidden = !unseen || nearBottom();
1125
- feedDown.textContent = unseen ? `↓ ${unseen}` : '↓';
1137
+ const show = !stickToBottom && distFromBottom() > BOTTOM_EPS;
1138
+ feedDown.hidden = !show;
1139
+ feedDown.textContent = unseen ? `↓ ${unseen} нов.` : '↓';
1126
1140
  };
1127
- const toBottom = () => { unseen = 0; scrollDown(true); updateDown(); };
1128
- feed.addEventListener('scroll', () => { if (nearBottom()) unseen = 0; updateDown(); });
1129
- feedDown.addEventListener('click', toBottom);
1141
+ const scrollDown = (force = false, smooth = false) => {
1142
+ if (force) stickToBottom = true;
1143
+ if (!stickToBottom) { updateDown(); return; }
1144
+ cancelAnimationFrame(feedScrollRaf);
1145
+ feedScrollRaf = requestAnimationFrame(() => {
1146
+ if (!stickToBottom) return;
1147
+ if (smooth) feed.scrollTo({ top: feed.scrollHeight, behavior: 'smooth' });
1148
+ else feed.scrollTop = feed.scrollHeight;
1149
+ lastFeedTop = feed.scrollTop;
1150
+ unseen = 0;
1151
+ updateDown();
1152
+ });
1153
+ };
1154
+ const toBottom = (smooth = false) => { unseen = 0; scrollDown(true, smooth); };
1155
+ const releaseStick = () => { if (distFromBottom() > 2) { stickToBottom = false; updateDown(); } };
1156
+ feed.addEventListener('wheel', (e) => {
1157
+ if (e.deltaY >= 0) return;
1158
+ // прокрутка внутри блока размышлений или таблицы, которые сами ещё листаются вверх, ленту не трогает
1159
+ const inner = e.target.closest?.('.think-body, .md-c pre, .md-c .tbl-wrap');
1160
+ if (inner && inner.scrollTop > 0) return;
1161
+ stickToBottom = false;
1162
+ updateDown();
1163
+ }, { passive: true });
1164
+ feed.addEventListener('touchmove', releaseStick, { passive: true });
1165
+ feed.addEventListener('keydown', (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) { stickToBottom = false; updateDown(); } });
1166
+ feed.addEventListener('scroll', () => {
1167
+ const top = feed.scrollTop;
1168
+ // прокрутка вверх (в том числе мышью за полосу прокрутки) — пользователь читает историю
1169
+ if (top < lastFeedTop - 4 && distFromBottom() > BOTTOM_EPS) stickToBottom = false;
1170
+ // пользователь сам вернулся вниз — снова следим за новыми сообщениями
1171
+ if (distFromBottom() <= BOTTOM_EPS) { stickToBottom = true; unseen = 0; }
1172
+ lastFeedTop = top;
1173
+ updateDown();
1174
+ }, { passive: true });
1175
+ feedDown.addEventListener('click', () => toBottom(true));
1130
1176
  const msgEls = new Map();
1131
1177
  const liveEls = new Map();
1132
1178
 
@@ -1199,7 +1245,7 @@ function renderMsg(m) {
1199
1245
  function upsertMsg(m) {
1200
1246
  state.chat.msgs.set(m.id, m);
1201
1247
  const old = msgEls.get(m.id);
1202
- const stick = nearBottom();
1248
+ const stick = stickToBottom;
1203
1249
  const el = renderMsg(m);
1204
1250
  if (!el) { old?.remove(); msgEls.delete(m.id); return; }
1205
1251
  if (old) {
@@ -1232,7 +1278,7 @@ function onDelta(d) {
1232
1278
 
1233
1279
  function liveLine(key, html) {
1234
1280
  let el = liveEls.get(key);
1235
- const stick = nearBottom();
1281
+ const stick = stickToBottom;
1236
1282
  if (!el) { el = document.createElement('div'); el.className = 'live'; liveEls.set(key, el); feed.append(el); }
1237
1283
  el.innerHTML = html;
1238
1284
  if (stick) scrollDown(true);
@@ -1343,12 +1389,15 @@ function closeModeMenu() {
1343
1389
  if (m) m.hidden = true;
1344
1390
  }
1345
1391
 
1346
- async function sendChat(text) {
1392
+ async function sendChat(text, { newSession = false, title = null } = {}) {
1347
1393
  if (speechRec && speechActive) stopVoiceInput(false);
1348
1394
  text = (text ?? $('#chat-input').value).trim();
1349
1395
  if (!text) return;
1396
+ if (document.body.classList.contains('chat-closed')) setChatClosed(false);
1397
+ // новый запрос — лента снова «прилипает» к низу и показывает вопрос и потоковый ответ
1398
+ toBottom(true);
1350
1399
  try {
1351
- await post('/api/chat/send', { text, mode: 'advisor' });
1400
+ await post('/api/chat/send', { text, mode: 'advisor', newSession: Boolean(newSession), title: title || undefined });
1352
1401
  $('#chat-input').value = '';
1353
1402
  resizeChatInput();
1354
1403
  toBottom();
@@ -1357,7 +1406,83 @@ async function sendChat(text) {
1357
1406
 
1358
1407
  $('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!state.chat.state?.busy) sendChat(); } });
1359
1408
  $('#chat-stop').addEventListener('click', () => post('/api/chat/stop'));
1360
- $('#chat-new').addEventListener('click', async () => { if (confirm('Начать новый разговор? Журнал гипотез сохранится.')) await post('/api/chat/new'); });
1409
+
1410
+ // ---------- архив сессий со Стратегом (.md) ----------
1411
+ let currentArchiveView = null; // { id, title, fileName, markdown }
1412
+
1413
+ async function openChatArchiveList() {
1414
+ currentArchiveView = null;
1415
+ $('#archive-title').textContent = 'Архив сессий со Стратегом (.md)';
1416
+ $('#archive-back').hidden = true;
1417
+ $('#archive-open-chat').hidden = true;
1418
+ $('#archive-download-md').hidden = true;
1419
+ $('#archive-body').textContent = 'Загрузка…';
1420
+ $('#archive-modal').hidden = false;
1421
+ try {
1422
+ const { sessions } = await api('/api/chat/archive');
1423
+ if (!sessions?.length) {
1424
+ $('#archive-body').innerHTML = '<p class="muted">Архивных сессий пока нет. Каждая беседа со Стратегом автоматически сохраняется в архив в формате Markdown (.md) с датой, временем и темой запроса.</p>';
1425
+ return;
1426
+ }
1427
+ $('#archive-body').innerHTML = `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Тема сессии</th><th>Файл (.md)</th><th class="num">Сообщ.</th><th style="text-align:right">Действия</th></tr></thead><tbody>${sessions.map((s) => `<tr>
1428
+ <td class="small" style="white-space:nowrap">${esc(s.timeLabel)}</td>
1429
+ <td><b>${esc(s.title)}</b>${s.active ? ' <span class="tag ok">текущая</span>' : ''}</td>
1430
+ <td class="small muted"><code>${esc(s.fileName)}</code></td>
1431
+ <td class="num small">${s.msgCount}</td>
1432
+ <td style="text-align:right;white-space:nowrap">
1433
+ <button type="button" class="small" data-arch-view="${s.id}">📖 Просмотр .md</button>
1434
+ <button type="button" class="primary small" data-arch-open="${s.id}">В чат</button>
1435
+ </td>
1436
+ </tr>`).join('')}</tbody></table></div>`;
1437
+ } catch (e) {
1438
+ $('#archive-body').textContent = e.message;
1439
+ }
1440
+ }
1441
+
1442
+ async function viewArchivedMarkdown(id) {
1443
+ $('#archive-body').textContent = 'Загрузка…';
1444
+ try {
1445
+ const info = await api(`/api/chat/archive/${id}/md`);
1446
+ currentArchiveView = info;
1447
+ $('#archive-title').textContent = `${info.title} · ${info.fileName}`;
1448
+ $('#archive-back').hidden = false;
1449
+ $('#archive-open-chat').hidden = false;
1450
+ $('#archive-download-md').hidden = false;
1451
+ $('#archive-body').innerHTML = `<div class="md">${md(info.markdown)}</div>`;
1452
+ } catch (e) {
1453
+ $('#archive-body').textContent = e.message;
1454
+ }
1455
+ }
1456
+
1457
+ $('#btn-chat-archive')?.addEventListener('click', openChatArchiveList);
1458
+ $('#archive-back')?.addEventListener('click', openChatArchiveList);
1459
+ $('#archive-close')?.addEventListener('click', () => { $('#archive-modal').hidden = true; });
1460
+ $('#archive-modal')?.addEventListener('click', async (e) => {
1461
+ if (e.target.id === 'archive-modal') { $('#archive-modal').hidden = true; return; }
1462
+ const vBtn = e.target.closest('button[data-arch-view]');
1463
+ if (vBtn) { await viewArchivedMarkdown(Number(vBtn.dataset.archView)); return; }
1464
+ const oBtn = e.target.closest('button[data-arch-open]');
1465
+ if (oBtn) {
1466
+ await post(`/api/chat/archive/${Number(oBtn.dataset.archOpen)}/open`);
1467
+ $('#archive-modal').hidden = true;
1468
+ if (document.body.classList.contains('chat-closed')) setChatClosed(false);
1469
+ }
1470
+ });
1471
+ $('#archive-open-chat')?.addEventListener('click', async () => {
1472
+ if (!currentArchiveView?.id) return;
1473
+ await post(`/api/chat/archive/${currentArchiveView.id}/open`);
1474
+ $('#archive-modal').hidden = true;
1475
+ if (document.body.classList.contains('chat-closed')) setChatClosed(false);
1476
+ });
1477
+ $('#archive-download-md')?.addEventListener('click', () => {
1478
+ if (!currentArchiveView?.markdown) return;
1479
+ const blob = new Blob([currentArchiveView.markdown], { type: 'text/markdown;charset=utf-8' });
1480
+ const a = document.createElement('a');
1481
+ a.href = URL.createObjectURL(blob);
1482
+ a.download = currentArchiveView.fileName || `session_${currentArchiveView.id}.md`;
1483
+ a.click();
1484
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
1485
+ });
1361
1486
 
1362
1487
  // ---------- голосовой ввод (Web Speech API) + звуковой сигнал БИП ----------
1363
1488
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
@@ -2459,7 +2584,7 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2459
2584
  } catch { /* ignore */ }
2460
2585
  }
2461
2586
  toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
2462
- await sendChat(promptText);
2587
+ await sendChat(promptText, { newSession: true, title: `Подключение счёта ${brkName}` });
2463
2588
  });
2464
2589
 
2465
2590
  function updateBrokerSaveBtnState() {
@@ -2896,7 +3021,21 @@ function connectWs() {
2896
3021
  if (!m.state.busy) state.chat.stream.clear();
2897
3022
  renderChatState();
2898
3023
  break;
2899
- case 'chat:reset': loadChat(); break;
3024
+ case 'chat:reset':
3025
+ feed.querySelectorAll('.msg, .ev').forEach((e) => { if (!e.classList.contains('live')) e.remove(); });
3026
+ msgEls.clear();
3027
+ state.chat.msgs.clear();
3028
+ state.chat.stream.clear();
3029
+ if (m.state) state.chat.state = m.state;
3030
+ if (Array.isArray(m.messages)) {
3031
+ for (const msg of m.messages) upsertMsg(msg);
3032
+ renderChatState();
3033
+ toBottom();
3034
+ } else {
3035
+ renderChatState();
3036
+ loadChat().catch(() => {});
3037
+ }
3038
+ break;
2900
3039
  case 'llm:changed':
2901
3040
  state.config.llm = m.llm;
2902
3041
  showLlmStatus();
@@ -3634,7 +3773,10 @@ $('#btn-stock-ask')?.addEventListener('click', () => {
3634
3773
  if (!currentStockData?.symbol) return;
3635
3774
  const sym = currentStockData.symbol;
3636
3775
  $('#stock-modal').hidden = true;
3637
- sendChat(`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`);
3776
+ sendChat(
3777
+ `Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`,
3778
+ { newSession: true, title: `Досье акции ${sym} (${currentStockData.name || sym})` },
3779
+ );
3638
3780
  });
3639
3781
 
3640
3782
  // Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
@@ -3650,6 +3792,8 @@ document.addEventListener('keydown', (e) => {
3650
3792
  if (e.key === 'Escape') {
3651
3793
  $('#help').hidden = true;
3652
3794
  if ($('#stock-modal')) $('#stock-modal').hidden = true;
3795
+ if ($('#events-modal')) $('#events-modal').hidden = true;
3796
+ if ($('#archive-modal')) $('#archive-modal').hidden = true;
3653
3797
  closeModelMenu();
3654
3798
  closeModeMenu();
3655
3799
  }
package/public/index.html CHANGED
@@ -27,13 +27,6 @@
27
27
 
28
28
  <main id="main">
29
29
  <section class="chat">
30
- <div class="chat-head">
31
- <b>Стратег</b>
32
- <span id="chat-status" class="muted small"></span>
33
- <span class="grow"></span>
34
- <button id="chat-new" class="icon small" title="Начать новый диалог со Стратегом">+ Новый диалог</button>
35
- <button id="chat-hide" class="icon small" title="Свернуть панель чата со Стратегом">◀</button>
36
- </div>
37
30
  <div class="feed-wrap">
38
31
  <div id="feed" class="feed"></div>
39
32
  <button id="feed-down" class="feed-down" hidden title="К новым сообщениям"></button>
@@ -43,7 +36,12 @@
43
36
  <div class="prompt-box" id="prompt-box">
44
37
  <textarea id="chat-input" rows="2" placeholder="Спросите Стратега о прогнозе, портфеле или настройках счёта…"></textarea>
45
38
  <div class="prompt-bar">
46
- <div class="prompt-left"></div>
39
+ <div class="prompt-left">
40
+ <button type="button" id="btn-chat-archive" class="ghost-select model-btn" title="Архив предыдущих сессий со Стратегом (.md)">
41
+ <span>🗂 Архив</span>
42
+ </button>
43
+ <span id="chat-status" class="muted small"></span>
44
+ </div>
47
45
  <div class="prompt-right">
48
46
  <div class="model-picker" id="model-picker">
49
47
  <button type="button" id="chat-model-btn" class="ghost-select model-btn" title="Модель ИИ (настраивается в «Настройки → Модели ИИ»)">
@@ -315,6 +313,21 @@
315
313
  </div>
316
314
  </div>
317
315
 
316
+ <!-- Архив сессий со Стратегом (.md) -->
317
+ <div id="archive-modal" class="modal" hidden>
318
+ <div class="modal-box stock-modal-box" role="dialog" aria-labelledby="archive-title">
319
+ <div class="modal-head" style="gap:10px;flex-wrap:wrap">
320
+ <b id="archive-title">Архив сессий со Стратегом</b>
321
+ <span class="grow"></span>
322
+ <button id="archive-back" type="button" class="small" hidden>← Все сессии</button>
323
+ <button id="archive-open-chat" type="button" class="primary small" hidden title="Загрузить эту сессию в панель чата и продолжить диалог">Открыть в чате</button>
324
+ <button id="archive-download-md" type="button" class="small" hidden title="Скачать файл сессии в формате Markdown (.md)">⬇ Скачать .md</button>
325
+ <button id="archive-close" class="icon" title="Закрыть (Esc)" aria-label="Закрыть">✕</button>
326
+ </div>
327
+ <div id="archive-body" class="stock-modal-body">Загрузка…</div>
328
+ </div>
329
+ </div>
330
+
318
331
  <!-- Модальное окно досье акции (прогнозы, соседи по карте, график, дивиденды, бизнес-профиль, сделки, новости) -->
319
332
  <div id="stock-modal" class="modal" hidden>
320
333
  <div class="modal-box stock-modal-box">
package/public/styles.css CHANGED
@@ -96,17 +96,26 @@ body.dragging iframe, body.dragging canvas { pointer-events: none; }
96
96
  .feed, .tab, .think-body, .modal .md { scrollbar-width: thin; scrollbar-color: #2a3140 transparent; }
97
97
 
98
98
  /* ---------- чат ---------- */
99
- .chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
99
+ .chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
100
100
  .chat-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
101
101
  .chat-head select { font-size: 12.5px; padding: 3px 6px; }
102
102
  .chat-head label { font-size: 12px; color: var(--muted); }
103
103
  .chat-head label input { width: 46px; padding: 2px 4px; }
104
104
  .feed-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
105
- .feed { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
106
- .feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; }
107
- .msg { max-width: 92%; }
105
+ /* Лента чата: только вертикальная прокрутка; широкое содержимое (таблицы, код) листается внутри своего блока */
106
+ .feed { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; min-width: 0; }
107
+ .feed > * { min-width: 0; max-width: 100%; }
108
+ .feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; z-index: 2; }
109
+ .msg { max-width: 92%; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
108
110
  .msg.user { align-self: flex-end; background: #1d3359; border-radius: 12px 12px 3px 12px; padding: 7px 12px; white-space: pre-wrap; }
109
- .msg.ai { align-self: stretch; }
111
+ .msg.ai { align-self: stretch; max-width: 100%; }
112
+ .md-c { min-width: 0; max-width: 100%; }
113
+ .md-c pre { max-width: 100%; overflow-x: auto; white-space: pre; background: var(--panel); padding: 8px 10px; border-radius: 6px; }
114
+ .md-c pre code { background: none; padding: 0; }
115
+ .md-c .tbl-wrap { max-width: 100%; overflow-x: auto; margin: 6px 0; }
116
+ .md-c .tbl-wrap table { margin: 0; }
117
+ .md-c img { max-width: 100%; height: auto; }
118
+ .ev, .live, .act { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
110
119
  .md-c > :first-child { margin-top: 0; }
111
120
  .md-c > :last-child { margin-bottom: 0; }
112
121
  .md-c p { margin: 6px 0; }
@@ -781,7 +781,8 @@ export function createRouter() {
781
781
  if (countUserChatToday() >= plan.maxChatPerDay) {
782
782
  throw new Error(`Достигнут дневной лимит сообщений ИИ-Стратегу по тарифу «${plan.name}» (${plan.maxChatPerDay} в сутки).`);
783
783
  }
784
- res.json({ message: strategist.sendUserMessage(req.body?.text, req.body?.mode) });
784
+ const { text, mode, newSession, title } = req.body || {};
785
+ res.json({ message: strategist.sendUserMessage(text, mode, { newSession: Boolean(newSession), title: title || null }) });
785
786
  }));
786
787
  r.post('/chat/action', wrap((req, res) => {
787
788
  const { msgId, callId, decision, comment } = req.body || {};
@@ -791,6 +792,13 @@ export function createRouter() {
791
792
  r.post('/chat/stop', (req, res) => { strategist.stopAgent(); res.json({ ok: true }); });
792
793
  r.post('/chat/mode', wrap((req, res) => { strategist.setMode(req.body?.mode, req.body?.budget); res.json(strategist.chatState()); }));
793
794
  r.post('/chat/new', (req, res) => { strategist.resetConversation(); res.json(strategist.history()); });
795
+ r.get('/chat/archive', (req, res) => res.json({ sessions: strategist.listArchivedSessions(120) }));
796
+ r.post('/chat/archive/:id/open', wrap((req, res) => res.json(strategist.openArchivedSession(Number(req.params.id)))));
797
+ r.get('/chat/archive/:id/md', wrap((req, res) => {
798
+ const info = strategist.exportConversationMarkdown(Number(req.params.id));
799
+ if (!info) throw Object.assign(new Error('Сессия не найдена или пуста'), { status: 404 });
800
+ res.json(info);
801
+ }));
794
802
 
795
803
  return r;
796
804
  }
package/server/db.js CHANGED
@@ -242,12 +242,31 @@ export function userKvSet(key, value, uid = getUid()) {
242
242
  // ---------- чат ----------
243
243
  export function currentConversation(uid = getUid()) {
244
244
  const r = db.prepare('SELECT * FROM conversations WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT 1').get(uid ?? 1);
245
- return r || newConversation(uid);
245
+ return r || newConversation(null, uid);
246
246
  }
247
- export function newConversation(uid = getUid()) {
248
- const id = Number(db.prepare('INSERT INTO conversations (created_at, user_id) VALUES (?, ?)').run(Date.now(), uid ?? 1).lastInsertRowid);
247
+ export function newConversation(title = null, uid = getUid()) {
248
+ const id = Number(db.prepare('INSERT INTO conversations (created_at, title, user_id) VALUES (?, ?, ?)').run(Date.now(), title, uid ?? 1).lastInsertRowid);
249
249
  return db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
250
250
  }
251
+ export function getConversation(id, uid = getUid()) {
252
+ return db.prepare('SELECT * FROM conversations WHERE id = ? AND COALESCE(user_id, 1) = ?').get(Number(id), uid ?? 1) || null;
253
+ }
254
+ export function setConversationTitle(id, title, uid = getUid()) {
255
+ db.prepare('UPDATE conversations SET title = ? WHERE id = ? AND COALESCE(user_id, 1) = ?').run(String(title || '').trim() || null, Number(id), uid ?? 1);
256
+ return getConversation(id, uid);
257
+ }
258
+ export function listConversations(limit = 100, uid = getUid()) {
259
+ return db.prepare(`
260
+ SELECT c.id, c.created_at, c.title,
261
+ (SELECT COUNT(*) FROM chat_messages m WHERE m.conv_id = c.id AND m.kind IN ('user', 'assistant')) AS msg_count,
262
+ (SELECT m.content FROM chat_messages m WHERE m.conv_id = c.id AND m.kind = 'user' ORDER BY m.id ASC LIMIT 1) AS first_user,
263
+ (SELECT MAX(m.ts) FROM chat_messages m WHERE m.conv_id = c.id) AS updated_at
264
+ FROM conversations c
265
+ WHERE COALESCE(c.user_id, 1) = ?
266
+ ORDER BY c.id DESC
267
+ LIMIT ?
268
+ `).all(uid ?? 1, limit).filter((r) => r.msg_count > 0);
269
+ }
251
270
  const parseMsg = (m) => m && ({
252
271
  ...m,
253
272
  tool_calls: m.tool_calls ? JSON.parse(m.tool_calls) : null,
@@ -1,20 +1,112 @@
1
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 { DATA_DIR } from '../config.js';
2
5
  import { PARAMS } from '../params-meta.js';
3
6
  import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
4
7
  import { hub } from '../hub.js';
5
8
  import { getSettings } from '../settings.js';
6
9
  import { brokersState } from '../brokers/index.js';
7
10
  import {
8
- currentConversation, newConversation, addMessage, updateMessage, getMessage, getMessages,
11
+ currentConversation, newConversation, getConversation, setConversationTitle, listConversations,
12
+ addMessage, updateMessage, getMessage, getMessages,
9
13
  kvGet, kvSet, currentUid,
10
14
  } from '../db.js';
11
15
  import { TOOLS, MODES, toolSchemas, OOS_LIMIT } from './tools.js';
12
16
 
17
+ const ARCHIVE_ROOT = path.join(DATA_DIR, 'chat-archive');
18
+ fs.mkdirSync(ARCHIVE_ROOT, { recursive: true });
19
+
13
20
  const MAX_STEPS = 20;
14
21
  const MSK = 3 * 3600000;
15
22
  const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
16
23
  const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
17
24
 
25
+ // Человекочитаемое имя сессии по первому вопросу пользователя или теме запроса
26
+ function deriveSessionTitle(rawText = '') {
27
+ const s = String(rawText || '').replace(/\s+/g, ' ').trim();
28
+ if (!s) return 'Диалог со Стратегом';
29
+ const runMatch = s.match(/исследование\s*№\s*(\d+)/i);
30
+ if (runMatch) return `Разбор исследования №${runMatch[1]}`;
31
+ if (/model_forecast|прогноз на ближайшие часы/i.test(s) && !/акцию/i.test(s)) return 'Прогноз и новости рынка';
32
+ const stockMatch = s.match(/Разбери подробно акцию\s+([A-Z0-9]+)\s*(?:\(([^)]+)\))?/i);
33
+ if (stockMatch) return `Досье акции ${stockMatch[1]}${stockMatch[2] ? ` (${stockMatch[2]})` : ''}`;
34
+ const brkMatch = s.match(/открыть и подключить брокерский счёт у брокера «([^»]+)»/i);
35
+ if (brkMatch) return `Подключение счёта ${brkMatch[1]}`;
36
+ const clean = s.replace(/[`*_~<>[\]()]/g, '').trim();
37
+ return clean.length > 56 ? `${clean.slice(0, 54).trim()}…` : clean;
38
+ }
39
+
40
+ const safeFileSlug = (title = '') => (
41
+ String(title || 'Диалог')
42
+ .replace(/[\\/:*?"<>|]/g, ' ')
43
+ .replace(/\s+/g, '_')
44
+ .replace(/^_+|_+$/g, '')
45
+ .slice(0, 48) || 'Диалог'
46
+ );
47
+
48
+ function userArchiveDir(uid = currentUid()) {
49
+ const d = path.join(ARCHIVE_ROOT, `u${Number(uid) || 1}`);
50
+ fs.mkdirSync(d, { recursive: true });
51
+ return d;
52
+ }
53
+
54
+ // Формирует и сохраняет сессию на диск в виде .md файла с понятным именем и временем МСК
55
+ export function exportConversationMarkdown(convId, uid = currentUid()) {
56
+ const c = getConversation(convId, uid);
57
+ if (!c) return null;
58
+ const msgs = getMessages(c.id, 600);
59
+ const firstUser = msgs.find((m) => m.kind === 'user');
60
+ if (!firstUser && !msgs.some((m) => m.kind === 'assistant' && m.content)) return null;
61
+ const title = c.title || deriveSessionTitle(firstUser?.content || '');
62
+ if (!c.title && title) setConversationTitle(c.id, title, uid);
63
+ const tsMsk = new Date((c.created_at || Date.now()) + MSK).toISOString();
64
+ const datePart = tsMsk.slice(0, 10);
65
+ const timePart = tsMsk.slice(11, 16).replace(':', '-');
66
+ const fileName = `${datePart}_${timePart}_${safeFileSlug(title)}_#${c.id}.md`;
67
+ const dir = userArchiveDir(uid);
68
+ // Удаляем старый файл этой же сессии, если заголовок уточнялся
69
+ try {
70
+ for (const f of fs.readdirSync(dir)) {
71
+ if (f.endsWith(`_#${c.id}.md`) && f !== fileName) fs.unlinkSync(path.join(dir, f));
72
+ }
73
+ } catch { /* ignore */ }
74
+
75
+ const lines = [
76
+ `# ${title}`,
77
+ '',
78
+ `- **Сессия:** №${c.id}`,
79
+ `- **Время (МСК):** ${fmtTs(c.created_at || Date.now())}`,
80
+ '',
81
+ '---',
82
+ '',
83
+ ];
84
+ for (const m of msgs) {
85
+ if (m.data?.hidden || m.kind === 'tool') continue;
86
+ const tStr = m.ts ? new Date(m.ts + MSK).toISOString().slice(11, 19) : '';
87
+ if (m.kind === 'user') {
88
+ lines.push(`### 👤 Вы (${tStr})`, '', m.content || '', '');
89
+ } else if (m.kind === 'event') {
90
+ lines.push(`> 📌 **Событие (${tStr}):** ${m.data?.display || m.content || ''}`, '');
91
+ } else if (m.role === 'assistant') {
92
+ if (!m.content && !m.tool_calls?.length) continue;
93
+ lines.push(`### 🤖 Стратег (${tStr})`, '');
94
+ if (m.tool_calls?.length) {
95
+ for (const tc of m.tool_calls) {
96
+ const st = m.data?.tools?.[tc.id] || {};
97
+ lines.push(`- *Инструмент \`${tc.function?.name}\`: ${st.summary || st.status || 'выполнен'}*`);
98
+ }
99
+ lines.push('');
100
+ }
101
+ if (m.content) lines.push(m.content, '');
102
+ }
103
+ }
104
+ const mdText = lines.join('\n');
105
+ const fullPath = path.join(dir, fileName);
106
+ try { fs.writeFileSync(fullPath, mdText, 'utf8'); } catch { /* ignore */ }
107
+ return { id: c.id, title, createdAt: c.created_at, fileName, markdown: mdText };
108
+ }
109
+
18
110
  // Состояние чата отдельно для каждого пользователя (uid берётся из контекста запроса)
19
111
  const states = new Map();
20
112
  function userState() {
@@ -155,16 +247,46 @@ ${modeText}
155
247
  - Используй списки и выделение жирным для удобного чтения.`;
156
248
  }
157
249
 
158
- // ---------- контекст для модели ----------
250
+ // ---------- контекст для модели (с автоматическим сжатием длинной истории диалога) ----------
251
+ const CONTEXT_CHAR_BUDGET = 28000;
159
252
  function buildContext(convId) {
160
- const all = getMessages(convId, 120);
161
- let start = 0;
162
- if (all.length > 80) {
163
- start = all.length - 80;
164
- while (start < all.length && !['user', 'event'].includes(all[start].kind)) start++;
253
+ const all = getMessages(convId, 200);
254
+ // 1) Вычисляем точку разреза так, чтобы хвост диалога укладывался в бюджет символов (или не более 36 последних сообщений),
255
+ // а все более ранние реплики сворачивались в компактную сводку контекста без потери ключевых вопросов и выводов!
256
+ let start = Math.max(0, all.length - 36);
257
+ let tailChars = 0;
258
+ for (let i = all.length - 1; i >= start; i--) {
259
+ const cLen = (all[i].content || '').length;
260
+ if (tailChars + cLen > CONTEXT_CHAR_BUDGET && all.length - i >= 8) {
261
+ start = i + 1;
262
+ break;
263
+ }
264
+ tailChars += Math.min(cLen, 3500);
165
265
  }
166
- const msgs = all.slice(start);
266
+ while (start < all.length && !['user', 'event'].includes(all[start].kind)) start++;
267
+
167
268
  const out = [];
269
+ if (start > 0) {
270
+ const head = all.slice(0, start);
271
+ const notes = [];
272
+ for (const m of head) {
273
+ if (m.kind === 'user' && m.content) {
274
+ notes.push(`• Вопрос: ${m.content.replace(/\s+/g, ' ').slice(0, 200)}`);
275
+ } else if (m.role === 'assistant' && (m.content || m.tool_calls?.length)) {
276
+ const tools = (m.tool_calls || []).map((c) => c.function?.name).filter(Boolean).join(', ');
277
+ const txt = (m.content || '').replace(/\s+/g, ' ').slice(0, 260);
278
+ notes.push(`• Ответ${tools ? ` [${tools}]` : ''}: ${txt}`);
279
+ }
280
+ }
281
+ if (notes.length) {
282
+ out.push({
283
+ role: 'user',
284
+ content: `[Сжатая сводка начала текущей сессии (${head.length} сообщ.)]\n${notes.slice(-18).join('\n')}`,
285
+ });
286
+ }
287
+ }
288
+
289
+ const msgs = all.slice(start);
168
290
  const deferred = [];
169
291
  let openIds = new Set();
170
292
  const toolResults = new Set(msgs.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
@@ -307,7 +429,11 @@ async function loop(mode = 'advisor') {
307
429
  await new Promise((res) => setTimeout(res, 1500 * attempt));
308
430
  continue;
309
431
  }
310
- msg = updateMessage(msg.id, { content: ctrl.signal.aborted ? '' : `⚠️ ИИ не ответил: ${e.message}`, data: { error: true } });
432
+ if (ctrl.signal.aborted) {
433
+ msg = updateMessage(msg.id, { content: '', data: { hidden: true } });
434
+ } else {
435
+ msg = updateMessage(msg.id, { content: `⚠️ ИИ не ответил: ${e.message}`, data: { error: true } });
436
+ }
311
437
  emitMsg(msg, 'chat:update');
312
438
  return;
313
439
  }
@@ -338,28 +464,42 @@ async function loop(mode = 'advisor') {
338
464
  async function runAgent(mode = 'advisor') {
339
465
  if (userState().run) throw new Error('ИИ уже работает');
340
466
  if (!isLlmEnabled()) throw new Error('ИИ не подключён: выберите модель в разделе «Модели ИИ»');
341
- userState().run = { ctrl: new AbortController(), mode };
467
+ const myConvId = conv().id;
468
+ const myRun = { ctrl: new AbortController(), mode, convId: myConvId };
469
+ userState().run = myRun;
342
470
  emitState();
343
471
  try {
344
472
  await loop(mode);
345
473
  } catch (e) {
346
474
  console.error(e);
347
- emitMsg(addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
475
+ if (conv().id === myConvId) {
476
+ emitMsg(addMessage(myConvId, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
477
+ }
348
478
  } finally {
349
- userState().run = null;
479
+ if (userState().run === myRun) userState().run = null;
480
+ try { exportConversationMarkdown(myConvId); } catch { /* ignore */ }
350
481
  emitState();
351
482
  }
352
483
  }
353
484
 
354
485
  // ---------- публичные действия ----------
355
- export function sendUserMessage(text) {
486
+ export function sendUserMessage(text, mode = 'advisor', { newSession = false, title = null } = {}) {
356
487
  text = String(text || '').trim();
357
488
  if (!text) throw new Error('Пустое сообщение');
489
+ if (newSession) {
490
+ resetConversation(title || deriveSessionTitle(text));
491
+ }
358
492
  if (userState().run) throw new Error('ИИ ещё работает — дождитесь или нажмите «Стоп»');
359
493
  if (userState().waiting) rejectPending('Пользователь не стал подтверждать и написал сообщение (см. ниже).');
494
+ const cur = conv();
495
+ if (!cur.title) {
496
+ const autoTitle = title || deriveSessionTitle(text);
497
+ userState().conv = setConversationTitle(cur.id, autoTitle) || { ...cur, title: autoTitle };
498
+ }
360
499
  const m = addMessage(conv().id, { role: 'user', kind: 'user', content: text });
361
500
  emitMsg(m);
362
- runAgent('advisor').catch(() => {});
501
+ try { exportConversationMarkdown(conv().id); } catch { /* ignore */ }
502
+ runAgent(mode || 'advisor').catch(() => {});
363
503
  return m;
364
504
  }
365
505
 
@@ -394,16 +534,21 @@ export function resolveAction(msgId, callId, decision, comment = '') {
394
534
  setToolStatus(msgId, callId, { status: 'rejected' });
395
535
  }
396
536
  if (!isLlmEnabled()) throw new Error('ИИ не подключён');
397
- userState().run = { ctrl: new AbortController(), mode };
537
+ const myConvId = conv().id;
538
+ const myRun = { ctrl: new AbortController(), mode, convId: myConvId };
539
+ userState().run = myRun;
398
540
  emitState();
399
541
  (async () => {
400
542
  try {
401
543
  const res = await processCalls(getMessage(msgId), mode);
402
544
  if (res === 'done') await loop(mode);
403
545
  } catch (e) {
404
- emitMsg(addMessage(conv().id, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
546
+ if (conv().id === myConvId) {
547
+ emitMsg(addMessage(myConvId, { role: 'assistant', kind: 'assistant', content: `⚠️ Ошибка: ${e.message}` }));
548
+ }
405
549
  } finally {
406
- userState().run = null;
550
+ if (userState().run === myRun) userState().run = null;
551
+ try { exportConversationMarkdown(myConvId); } catch { /* ignore */ }
407
552
  emitState();
408
553
  }
409
554
  })();
@@ -412,6 +557,7 @@ export function resolveAction(msgId, callId, decision, comment = '') {
412
557
  export function stopAgent() {
413
558
  if (userState().run) {
414
559
  userState().run.ctrl.abort();
560
+ userState().run = null;
415
561
  }
416
562
  if (userState().waiting) rejectPending('Пользователь остановил ИИ');
417
563
  emitState();
@@ -422,14 +568,49 @@ export function setMode() {
422
568
  emitState();
423
569
  }
424
570
 
425
- export function resetConversation() {
571
+ export function resetConversation(title = null) {
426
572
  stopAgent();
427
- userState().conv = newConversation();
573
+ if (userState().conv?.id) {
574
+ try { exportConversationMarkdown(userState().conv.id); } catch { /* ignore */ }
575
+ }
576
+ userState().conv = newConversation(title);
428
577
  userState().waiting = null;
429
578
  hub.emit({ type: 'chat:reset', state: chatState() });
430
579
  return userState().conv;
431
580
  }
432
581
 
582
+ export function listArchivedSessions(limit = 100) {
583
+ const rows = listConversations(limit);
584
+ return rows.map((r) => {
585
+ const title = r.title || deriveSessionTitle(r.first_user || '');
586
+ const tsMsk = new Date((r.created_at || Date.now()) + MSK).toISOString();
587
+ const fileName = `${tsMsk.slice(0, 10)}_${tsMsk.slice(11, 16).replace(':', '-')}_${safeFileSlug(title)}_#${r.id}.md`;
588
+ return {
589
+ id: r.id,
590
+ title,
591
+ createdAt: r.created_at,
592
+ updatedAt: r.updated_at || r.created_at,
593
+ timeLabel: fmtTs(r.created_at),
594
+ msgCount: r.msg_count,
595
+ fileName,
596
+ active: r.id === conv().id,
597
+ };
598
+ });
599
+ }
600
+
601
+ export function openArchivedSession(id) {
602
+ stopAgent();
603
+ const c = getConversation(Number(id));
604
+ if (!c) throw new Error('Сессия не найдена');
605
+ userState().conv = c;
606
+ userState().waiting = null;
607
+ const info = exportConversationMarkdown(c.id);
608
+ const st = chatState();
609
+ const messages = getMessages(c.id, 400);
610
+ hub.emit({ type: 'chat:reset', state: st, messages });
611
+ return { state: st, messages, archive: info };
612
+ }
613
+
433
614
  export function history() {
434
615
  return { state: chatState(), messages: getMessages(conv().id, 300) };
435
616
  }