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 +1 -1
- package/public/app.js +130 -22
- package/public/index.html +21 -8
- package/server/api/routes.js +9 -1
- package/server/brokers/index.js +49 -2
- package/server/db.js +22 -3
- package/server/strategist/index.js +200 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
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 = !
|
|
387
|
+
$('#btn-ask-run').disabled = !state.chat.state?.llm;
|
|
388
388
|
$('#btn-ask-run').title = !state.chat.state?.llm
|
|
389
389
|
? 'ИИ не подключён (Настройки → Модели ИИ)'
|
|
390
|
-
:
|
|
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 = !
|
|
407
|
+
$('#btn-ask-forecast').disabled = !state.chat.state?.llm || !state.forecast;
|
|
410
408
|
$('#btn-ask-forecast').title = !state.forecast
|
|
411
409
|
? 'Сначала дождитесь расчёта прогноза'
|
|
412
|
-
: !
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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
|
|
2364
|
-
<div class="kpi"><div class="v ${ls.cashRub > 1000 ? 'pos' : 'muted'}">${fmtNum(ls.cashRub, 0)} ₽</div><div class="l">свободные рубли для покупок</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':
|
|
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(
|
|
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"
|
|
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/server/api/routes.js
CHANGED
|
@@ -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
|
-
|
|
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/brokers/index.js
CHANGED
|
@@ -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,
|
|
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,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|