neuroalpha 0.5.9 → 0.5.11

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.9",
3
+ "version": "0.5.11",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -384,12 +384,10 @@ function syncUiAvailability() {
384
384
  if ($('#btn-rename-run')) $('#btn-rename-run').hidden = !isDone;
385
385
  if ($('#btn-ask-run')) {
386
386
  $('#btn-ask-run').hidden = !isDone;
387
- $('#btn-ask-run').disabled = !aiReady;
387
+ $('#btn-ask-run').disabled = !state.chat.state?.llm;
388
388
  $('#btn-ask-run').title = !state.chat.state?.llm
389
389
  ? 'ИИ не подключён (Настройки → Модели ИИ)'
390
- : aiBusy
391
- ? 'Дождитесь завершения текущего ответа Стратега'
392
- : 'Попросить Стратега разобрать этот эксперимент';
390
+ : 'Начать новый диалог со Стратегом по этому эксперименту';
393
391
  }
394
392
  if ($('#btn-del')) $('#btn-del').hidden = !r || isRunning;
395
393
  syncApplyBtn();
@@ -406,12 +404,12 @@ function syncUiAvailability() {
406
404
  $('#btn-refresh-forecast').textContent = forecastBusy ? 'Обновляем…' : '↻ Обновить';
407
405
  }
408
406
  if ($('#btn-ask-forecast')) {
409
- $('#btn-ask-forecast').disabled = !aiReady || !state.forecast;
407
+ $('#btn-ask-forecast').disabled = !state.chat.state?.llm || !state.forecast;
410
408
  $('#btn-ask-forecast').title = !state.forecast
411
409
  ? 'Сначала дождитесь расчёта прогноза'
412
- : !aiReady
413
- ? 'Стратег сейчас занят или ИИ не подключён'
414
- : 'Попросить Стратега разобрать текущий прогноз';
410
+ : !state.chat.state?.llm
411
+ ? 'ИИ не подключён (Настройки → Модели ИИ)'
412
+ : 'Начать новый диалог со Стратегом по текущему прогнозу';
415
413
  }
416
414
  document.querySelectorAll('[data-live-ask]').forEach((b) => {
417
415
  b.disabled = !aiReady;
@@ -868,7 +866,10 @@ $('#run-bar').addEventListener('click', async (e) => {
868
866
  return;
869
867
  }
870
868
  if (e.target.closest('#btn-ask-run')) {
871
- sendChat(`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`);
869
+ sendChat(
870
+ `Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`,
871
+ { newSession: true, title: `Разбор исследования №${r.id}` },
872
+ );
872
873
  }
873
874
  });
874
875
  $('#btn-refresh-forecast')?.addEventListener('click', () => {
@@ -876,7 +877,10 @@ $('#btn-refresh-forecast')?.addEventListener('click', () => {
876
877
  loadForecast(false, false);
877
878
  });
878
879
  $('#btn-ask-forecast').addEventListener('click', () => {
879
- sendChat('Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?');
880
+ sendChat(
881
+ 'Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?',
882
+ { newSession: true, title: 'Прогноз и новости рынка' },
883
+ );
880
884
  });
881
885
 
882
886
  function setProgress(pct, text) {
@@ -1385,14 +1389,18 @@ function closeModeMenu() {
1385
1389
  if (m) m.hidden = true;
1386
1390
  }
1387
1391
 
1388
- async function sendChat(text) {
1392
+ async function sendChat(text, { newSession = false, title = null } = {}) {
1389
1393
  if (speechRec && speechActive) stopVoiceInput(false);
1390
1394
  text = (text ?? $('#chat-input').value).trim();
1391
1395
  if (!text) return;
1396
+ setChatClosed(false);
1397
+ if (newSession && window.innerWidth <= 920) {
1398
+ document.querySelector('section.chat')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1399
+ }
1392
1400
  // новый запрос — лента снова «прилипает» к низу и показывает вопрос и потоковый ответ
1393
1401
  toBottom(true);
1394
1402
  try {
1395
- await post('/api/chat/send', { text, mode: 'advisor' });
1403
+ await post('/api/chat/send', { text, mode: 'advisor', newSession: Boolean(newSession), title: title || undefined });
1396
1404
  $('#chat-input').value = '';
1397
1405
  resizeChatInput();
1398
1406
  toBottom();
@@ -1401,7 +1409,83 @@ async function sendChat(text) {
1401
1409
 
1402
1410
  $('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!state.chat.state?.busy) sendChat(); } });
1403
1411
  $('#chat-stop').addEventListener('click', () => post('/api/chat/stop'));
1404
- $('#chat-new').addEventListener('click', async () => { if (confirm('Начать новый разговор? Журнал гипотез сохранится.')) await post('/api/chat/new'); });
1412
+
1413
+ // ---------- архив сессий со Стратегом (.md) ----------
1414
+ let currentArchiveView = null; // { id, title, fileName, markdown }
1415
+
1416
+ async function openChatArchiveList() {
1417
+ currentArchiveView = null;
1418
+ $('#archive-title').textContent = 'Архив сессий со Стратегом (.md)';
1419
+ $('#archive-back').hidden = true;
1420
+ $('#archive-open-chat').hidden = true;
1421
+ $('#archive-download-md').hidden = true;
1422
+ $('#archive-body').textContent = 'Загрузка…';
1423
+ $('#archive-modal').hidden = false;
1424
+ try {
1425
+ const { sessions } = await api('/api/chat/archive');
1426
+ if (!sessions?.length) {
1427
+ $('#archive-body').innerHTML = '<p class="muted">Архивных сессий пока нет. Каждая беседа со Стратегом автоматически сохраняется в архив в формате Markdown (.md) с датой, временем и темой запроса.</p>';
1428
+ return;
1429
+ }
1430
+ $('#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>
1431
+ <td class="small" style="white-space:nowrap">${esc(s.timeLabel)}</td>
1432
+ <td><b>${esc(s.title)}</b>${s.active ? ' <span class="tag ok">текущая</span>' : ''}</td>
1433
+ <td class="small muted"><code>${esc(s.fileName)}</code></td>
1434
+ <td class="num small">${s.msgCount}</td>
1435
+ <td style="text-align:right;white-space:nowrap">
1436
+ <button type="button" class="small" data-arch-view="${s.id}">📖 Просмотр .md</button>
1437
+ <button type="button" class="primary small" data-arch-open="${s.id}">В чат</button>
1438
+ </td>
1439
+ </tr>`).join('')}</tbody></table></div>`;
1440
+ } catch (e) {
1441
+ $('#archive-body').textContent = e.message;
1442
+ }
1443
+ }
1444
+
1445
+ async function viewArchivedMarkdown(id) {
1446
+ $('#archive-body').textContent = 'Загрузка…';
1447
+ try {
1448
+ const info = await api(`/api/chat/archive/${id}/md`);
1449
+ currentArchiveView = info;
1450
+ $('#archive-title').textContent = `${info.title} · ${info.fileName}`;
1451
+ $('#archive-back').hidden = false;
1452
+ $('#archive-open-chat').hidden = false;
1453
+ $('#archive-download-md').hidden = false;
1454
+ $('#archive-body').innerHTML = `<div class="md">${md(info.markdown)}</div>`;
1455
+ } catch (e) {
1456
+ $('#archive-body').textContent = e.message;
1457
+ }
1458
+ }
1459
+
1460
+ $('#btn-chat-archive')?.addEventListener('click', openChatArchiveList);
1461
+ $('#archive-back')?.addEventListener('click', openChatArchiveList);
1462
+ $('#archive-close')?.addEventListener('click', () => { $('#archive-modal').hidden = true; });
1463
+ $('#archive-modal')?.addEventListener('click', async (e) => {
1464
+ if (e.target.id === 'archive-modal') { $('#archive-modal').hidden = true; return; }
1465
+ const vBtn = e.target.closest('button[data-arch-view]');
1466
+ if (vBtn) { await viewArchivedMarkdown(Number(vBtn.dataset.archView)); return; }
1467
+ const oBtn = e.target.closest('button[data-arch-open]');
1468
+ if (oBtn) {
1469
+ await post(`/api/chat/archive/${Number(oBtn.dataset.archOpen)}/open`);
1470
+ $('#archive-modal').hidden = true;
1471
+ if (document.body.classList.contains('chat-closed')) setChatClosed(false);
1472
+ }
1473
+ });
1474
+ $('#archive-open-chat')?.addEventListener('click', async () => {
1475
+ if (!currentArchiveView?.id) return;
1476
+ await post(`/api/chat/archive/${currentArchiveView.id}/open`);
1477
+ $('#archive-modal').hidden = true;
1478
+ if (document.body.classList.contains('chat-closed')) setChatClosed(false);
1479
+ });
1480
+ $('#archive-download-md')?.addEventListener('click', () => {
1481
+ if (!currentArchiveView?.markdown) return;
1482
+ const blob = new Blob([currentArchiveView.markdown], { type: 'text/markdown;charset=utf-8' });
1483
+ const a = document.createElement('a');
1484
+ a.href = URL.createObjectURL(blob);
1485
+ a.download = currentArchiveView.fileName || `session_${currentArchiveView.id}.md`;
1486
+ a.click();
1487
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
1488
+ });
1405
1489
 
1406
1490
  // ---------- голосовой ввод (Web Speech API) + звуковой сигнал БИП ----------
1407
1491
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
@@ -2326,7 +2410,7 @@ function renderBrokers() {
2326
2410
  const isPaused = a.autoSchedule === false || a.mode === 'readonly';
2327
2411
  const isEditingThis = editingBrokerId === a.id;
2328
2412
  const collapsedMetricsHtml = !isOpen && ls?.ok
2329
- ? `<span class="small muted" style="margin-left:6px">Всего: <b style="color:var(--text)">${fmtNum(ls.totalRub, 0)} ₽</b> · Свободно: <b class="${ls.cashRub > 1000 ? 'pos' : ''}">${fmtNum(ls.cashRub, 0)} ₽</b> · Финрез.: <b class="${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</b></span>`
2413
+ ? `<span class="small muted" style="margin-left:6px">Всего: <b style="color:var(--text)">${fmtNum(ls.totalRub, 0)} ₽</b> · Свободно: <b class="${ls.cashRub > 1000 ? 'pos' : ''}">${fmtNum(ls.cashRub, 0)} ₽</b>${ls.marginInfo ? ` · Марж. запас: <b class="pos">${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</b>` : ''} · Финрез.: <b class="${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</b>${a.executionAudit?.periods ? ` · 30 дн.: <b class="${cls(a.executionAudit.periods.month.netRub)}">${a.executionAudit.periods.month.netRub >= 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.month.netRub, 0)} ₽</b>` : ''}</span>`
2330
2414
  : '';
2331
2415
 
2332
2416
  return `<div class="live-card${isOpen ? '' : ' collapsed'}" data-bid="${esc(a.id)}">
@@ -2354,16 +2438,21 @@ function renderBrokers() {
2354
2438
  <span class="k">Брокер и номер счёта:</span><span class="v"><b>${esc(BROKER_LABELS[a.broker] || a.broker)}</b> · <code>${esc(a.accountId || ls?.accountId || 'авто')}</code>${ls?.at ? ` <span class="muted">(обновлён ${fmtTs(ls.at)})</span>` : ''}</span>
2355
2439
  <span class="k">Режим работы счёта:</span><span class="v"><span class="tag ${a.mode === 'auto' ? 'mid' : a.mode === 'confirm' ? 'ok' : ''}">${esc(BROKER_MODES[a.mode] || a.mode)}</span> · авто-торговля по расписанию: <b>${a.autoSchedule !== false ? '<span class="pos">вкл.</span>' : '<span class="muted">на паузе</span>'}</b></span>
2356
2440
  <span class="k">Начальный портфель:</span><span class="v">${esc(LEGACY_MODES[lMode] || lMode)}</span>
2357
- <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2441
+ <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? `<b class="pos">вкл. (шорт доступен)</b>${ls.marginInfo ? ` · ликвидный портфель <b>${fmtNum(ls.marginInfo.liquidPortfolioRub, 0)} ₽</b> · доступный запас <b class="pos">${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</b> · нач. маржа <b>${fmtNum(ls.marginInfo.startingMarginRub, 0)} ₽</b> (мин. ${fmtNum(ls.marginInfo.minimalMarginRub, 0)} ₽)${ls.marginInfo.fundsSufficiencyLevel > 0 ? ` · достаточность <b>${fmtNum(ls.marginInfo.fundsSufficiencyLevel, 2)}×</b>` : ''}${ls.marginInfo.missingFundsRub > 0 ? ` · <b class="neg">нехватка ${fmtNum(ls.marginInfo.missingFundsRub, 0)} ₽</b>` : ''}` : ''}` : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2442
+ ${a.executionAudit?.periods ? (() => {
2443
+ const pr = a.executionAudit.periods;
2444
+ const fmtP = (lbl, x) => `${lbl}: <b class="${cls(x.netRub)}">${x.netRub > 0 ? '+' : ''}${fmtNum(x.netRub, 0)} ₽</b> <span class="muted small">(${x.pct > 0 ? '+' : ''}${fmtNum(x.pct, 2)}%)</span>`;
2445
+ return `<span class="k">Доход / убыток по периодам:</span><span class="v" title="Чистый результат счёта (закрытые сделки после комиссий + дивиденды/купоны; за сегодня — с учётом дневной переоценки портфеля)">${fmtP('сегодня', pr.day)} · ${fmtP('7 дн.', pr.week)} · ${fmtP('30 дн.', pr.month)} · ${fmtP('с начала года', pr.ytd)}</span>`;
2446
+ })() : ''}
2358
2447
  <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2359
2448
  <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2360
2449
  ${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
2361
2450
  </div>
2362
2451
  ${ls?.ok ? `<div class="kpis">
2363
- <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
2364
- <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div></div>
2365
- <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div></div>
2366
- <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div></div>
2452
+ <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div>${ls.marginInfo ? `<div class="sub">марж. запас: ${fmtNum(ls.marginInfo.freeMarginRub, 0)} ₽</div>` : ''}</div>
2453
+ <div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</div>${ls.marginInfo?.startingMarginRub > 0 ? `<div class="sub">занято маржи: ${fmtNum(ls.marginInfo.startingMarginRub, 0)} ₽</div>` : ''}</div>
2454
+ <div class="kpi"><div class="v ${cls(totalPnlRub)}">${totalPnlRub >= 0 ? '+' : ''}${fmtNum(totalPnlRub, 0)} ₽</div><div class="l">финрезультат открытых позиций</div>${a.executionAudit?.periods ? `<div class="sub">за 30 дн.: <b class="${cls(a.executionAudit.periods.month.netRub)}">${a.executionAudit.periods.month.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.month.netRub, 0)} ₽</b></div>` : ''}</div>
2455
+ <div class="kpi"><div class="v">${fmtNum(strategyRub, 0)} ₽ <span class="muted small">/ ${fmtNum(protectedRub, 0)} ₽</span></div><div class="l">в стратегии / защищено</div>${a.executionAudit?.periods ? `<div class="sub">сегодня: <b class="${cls(a.executionAudit.periods.day.netRub)}">${a.executionAudit.periods.day.netRub > 0 ? '+' : ''}${fmtNum(a.executionAudit.periods.day.netRub, 0)} ₽</b></div>` : ''}</div>
2367
2456
  </div>` : ''}
2368
2457
  ${ls && !ls.ok ? `<p class="neg small">Ошибка связи с брокером: ${esc(ls.error)}</p>` : ''}
2369
2458
  <div class="brk-subtabs">
@@ -2503,7 +2592,7 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
2503
2592
  } catch { /* ignore */ }
2504
2593
  }
2505
2594
  toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
2506
- await sendChat(promptText);
2595
+ await sendChat(promptText, { newSession: true, title: `Подключение счёта ${brkName}` });
2507
2596
  });
2508
2597
 
2509
2598
  function updateBrokerSaveBtnState() {
@@ -2940,7 +3029,21 @@ function connectWs() {
2940
3029
  if (!m.state.busy) state.chat.stream.clear();
2941
3030
  renderChatState();
2942
3031
  break;
2943
- case 'chat:reset': loadChat(); break;
3032
+ case 'chat:reset':
3033
+ feed.querySelectorAll('.msg, .ev').forEach((e) => { if (!e.classList.contains('live')) e.remove(); });
3034
+ msgEls.clear();
3035
+ state.chat.msgs.clear();
3036
+ state.chat.stream.clear();
3037
+ if (m.state) state.chat.state = m.state;
3038
+ if (Array.isArray(m.messages)) {
3039
+ for (const msg of m.messages) upsertMsg(msg);
3040
+ renderChatState();
3041
+ toBottom();
3042
+ } else {
3043
+ renderChatState();
3044
+ loadChat().catch(() => {});
3045
+ }
3046
+ break;
2944
3047
  case 'llm:changed':
2945
3048
  state.config.llm = m.llm;
2946
3049
  showLlmStatus();
@@ -3678,7 +3781,10 @@ $('#btn-stock-ask')?.addEventListener('click', () => {
3678
3781
  if (!currentStockData?.symbol) return;
3679
3782
  const sym = currentStockData.symbol;
3680
3783
  $('#stock-modal').hidden = true;
3681
- sendChat(`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`);
3784
+ sendChat(
3785
+ `Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`,
3786
+ { newSession: true, title: `Досье акции ${sym} (${currentStockData.name || sym})` },
3787
+ );
3682
3788
  });
3683
3789
 
3684
3790
  // Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
@@ -3694,6 +3800,8 @@ document.addEventListener('keydown', (e) => {
3694
3800
  if (e.key === 'Escape') {
3695
3801
  $('#help').hidden = true;
3696
3802
  if ($('#stock-modal')) $('#stock-modal').hidden = true;
3803
+ if ($('#events-modal')) $('#events-modal').hidden = true;
3804
+ if ($('#archive-modal')) $('#archive-modal').hidden = true;
3697
3805
  closeModelMenu();
3698
3806
  closeModeMenu();
3699
3807
  }
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">
@@ -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
  }
@@ -287,7 +287,7 @@ const executionAuditCache = new Map(); // accountId -> { sig, val }
287
287
  function computeExecutionAudit(a) {
288
288
  const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
289
289
  if (!ops.length) return null;
290
- const sig = `${a.lastHistory?.at || 0}:${ops.length}:${ops[0]?.id || ''}:${ops[ops.length - 1]?.id || ''}`;
290
+ const sig = `${a.lastHistory?.at || 0}:${ops.length}:${ops[0]?.id || ''}:${ops[ops.length - 1]?.id || ''}:${Math.round(Number(a.lastSync?.dailyYieldRub || 0))}:${Math.round(Number(a.lastSync?.totalRub || 0))}`;
291
291
  const hit = executionAuditCache.get(a.id);
292
292
  if (hit && hit.sig === sig) return hit.val;
293
293
  const sorted = [...ops].sort((x, y) => x.date - y.date);
@@ -320,8 +320,9 @@ function computeExecutionAudit(a) {
320
320
  const slipTier2Pct = rubW2 > 0 ? round(slipW2 / rubW2, 3) : null;
321
321
  const totalCostPerSidePct = slipAllPct !== null ? round(commPct + Math.max(0, slipAllPct), 3) : commPct;
322
322
 
323
- // FIFO закрытых позиций (без старого портфеля baselineQty)
323
+ // FIFO закрытых позиций (без старого портфеля baselineQty) + разбивка финансового результата по периодам (день, 7 дн., 30 дн., с начала года)
324
324
  const book = new Map();
325
+ const closedEvents = [];
325
326
  let winRub = 0, lossRub = 0, closedCount = 0;
326
327
  for (const t of trades) {
327
328
  const q = t.kind === 'buy' ? t.qty : -t.qty;
@@ -332,6 +333,7 @@ function computeExecutionAudit(a) {
332
333
  const take = Math.min(Math.abs(rem), Math.abs(lots[0].qty));
333
334
  const pnl = lots[0].qty > 0 ? take * (t.price - lots[0].price) : take * (lots[0].price - t.price);
334
335
  closedCount++;
336
+ closedEvents.push({ ts: t.date || 0, pnl });
335
337
  if (pnl >= 0) winRub += pnl; else lossRub += -pnl;
336
338
  lots[0].qty -= Math.sign(lots[0].qty) * take;
337
339
  rem -= Math.sign(rem) * take;
@@ -344,6 +346,31 @@ function computeExecutionAudit(a) {
344
346
  const netRub = round(grossRub - feesRub, 0);
345
347
  const profitFactorGross = lossRub > 0 ? round(winRub / lossRub, 2) : (winRub > 0 ? 3 : null);
346
348
 
349
+ const nowTs = Date.now();
350
+ const nowMsk = new Date(nowTs + MSK);
351
+ const todayStartTs = Date.parse(`${nowMsk.toISOString().slice(0, 10)}T00:00:00+03:00`);
352
+ const yearStartTs = Date.parse(`${nowMsk.getUTCFullYear()}-01-01T00:00:00+03:00`);
353
+ const baseCap = Math.max(1, Number(a.lastSync?.totalRub || a.initialTotalRub || a.maxCapitalRub || 100000));
354
+ const calcPeriodPnl = (fromTs, includeUnrealizedToday = false) => {
355
+ const g = closedEvents.filter((c) => c.ts >= fromTs).reduce((s, c) => s + c.pnl, 0);
356
+ const f = algoOps.filter((o) => o.kind === 'fee' && (o.date || 0) >= fromTs).reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
357
+ const d = sorted.filter((o) => o.kind === 'div' && (o.date || 0) >= fromTs).reduce((s, o) => s + (Number(o.paymentRub) || 0), 0);
358
+ const cCnt = closedEvents.filter((c) => c.ts >= fromTs).length;
359
+ let total = round(g - f + d, 0);
360
+ if (includeUnrealizedToday && cCnt === 0 && Math.abs(total) < 1 && a.lastSync?.dailyYieldRub) {
361
+ total = round(Number(a.lastSync.dailyYieldRub) || 0, 0);
362
+ } else if (includeUnrealizedToday && cCnt === 0 && Math.abs(total) < 1 && a.lastSync?.dayStartRub > 0 && a.lastSync?.totalRub > 0) {
363
+ total = round(a.lastSync.totalRub - a.lastSync.dayStartRub, 0);
364
+ }
365
+ return { netRub: total, pct: round((total / baseCap) * 100, 2), closed: cCnt };
366
+ };
367
+ const periods = {
368
+ day: calcPeriodPnl(todayStartTs, true),
369
+ week: calcPeriodPnl(nowTs - 7 * DAY, false),
370
+ month: calcPeriodPnl(nowTs - 30 * DAY, false),
371
+ ytd: calcPeriodPnl(yearStartTs, false),
372
+ };
373
+
347
374
  const val = {
348
375
  fromTs: algoOps[0]?.date || null,
349
376
  tradesCount: trades.length,
@@ -359,6 +386,7 @@ function computeExecutionAudit(a) {
359
386
  feesRub: round(feesRub, 0),
360
387
  netRub,
361
388
  profitFactorGross,
389
+ periods,
362
390
  };
363
391
  executionAuditCache.set(a.id, { sig, val });
364
392
  return val;
@@ -856,10 +884,28 @@ async function syncTbank(acc) {
856
884
  }
857
885
  }
858
886
  let marginEnabled = false;
887
+ let marginInfo = null;
859
888
  if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
860
889
  marginEnabled = false;
861
890
  } else if (rMargin?.ok) {
862
891
  marginEnabled = true;
892
+ const jMar = await rMargin.json().catch(() => null);
893
+ if (jMar) {
894
+ const liquidPortfolioRub = round(qVal(jMar.liquidPortfolio), 0);
895
+ const startingMarginRub = round(qVal(jMar.startingMargin), 0);
896
+ const minimalMarginRub = round(qVal(jMar.minimalMargin), 0);
897
+ const missingFundsRub = round(qVal(jMar.amountOfMissingFunds), 0);
898
+ const fundsSufficiencyLevel = round(qVal(jMar.fundsSufficiencyLevel), 2);
899
+ const freeMarginRub = round(Math.max(0, liquidPortfolioRub - startingMarginRub), 0);
900
+ marginInfo = {
901
+ liquidPortfolioRub,
902
+ startingMarginRub,
903
+ minimalMarginRub,
904
+ freeMarginRub,
905
+ missingFundsRub,
906
+ fundsSufficiencyLevel,
907
+ };
908
+ }
863
909
  } else if (rMargin) {
864
910
  const mTxt = await rMargin.text().catch(() => '');
865
911
  marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
@@ -991,6 +1037,7 @@ async function syncTbank(acc) {
991
1037
  accountName,
992
1038
  accountType,
993
1039
  marginEnabled,
1040
+ marginInfo,
994
1041
  autoFollowDetected,
995
1042
  totalRub: totalRub || cashRub,
996
1043
  cashRub: round(cashRub, 2),
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
  }