neuroalpha 0.5.8 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +170 -26
- package/public/index.html +21 -8
- package/public/styles.css +14 -5
- package/server/api/routes.js +9 -1
- 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.10",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -72,7 +72,9 @@ const md = (s) => {
|
|
|
72
72
|
? `${p1}<span class="sym-link" data-sym="${sym}" title="Открыть досье акции ${sym}"><b>${sym}</b></span>`
|
|
73
73
|
: m
|
|
74
74
|
));
|
|
75
|
-
|
|
75
|
+
// широкие таблицы листаются внутри своей обёртки, а не растягивают ленту чата
|
|
76
|
+
const withWraps = withSyms.replace(/<table>/g, '<div class="tbl-wrap"><table>').replace(/<\/table>/g, '</table></div>');
|
|
77
|
+
return DOMPurify.sanitize(withWraps, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
|
|
76
78
|
};
|
|
77
79
|
const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
|
|
78
80
|
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
|
@@ -382,12 +384,10 @@ function syncUiAvailability() {
|
|
|
382
384
|
if ($('#btn-rename-run')) $('#btn-rename-run').hidden = !isDone;
|
|
383
385
|
if ($('#btn-ask-run')) {
|
|
384
386
|
$('#btn-ask-run').hidden = !isDone;
|
|
385
|
-
$('#btn-ask-run').disabled = !
|
|
387
|
+
$('#btn-ask-run').disabled = !state.chat.state?.llm;
|
|
386
388
|
$('#btn-ask-run').title = !state.chat.state?.llm
|
|
387
389
|
? 'ИИ не подключён (Настройки → Модели ИИ)'
|
|
388
|
-
:
|
|
389
|
-
? 'Дождитесь завершения текущего ответа Стратега'
|
|
390
|
-
: 'Попросить Стратега разобрать этот эксперимент';
|
|
390
|
+
: 'Начать новый диалог со Стратегом по этому эксперименту';
|
|
391
391
|
}
|
|
392
392
|
if ($('#btn-del')) $('#btn-del').hidden = !r || isRunning;
|
|
393
393
|
syncApplyBtn();
|
|
@@ -404,12 +404,12 @@ function syncUiAvailability() {
|
|
|
404
404
|
$('#btn-refresh-forecast').textContent = forecastBusy ? 'Обновляем…' : '↻ Обновить';
|
|
405
405
|
}
|
|
406
406
|
if ($('#btn-ask-forecast')) {
|
|
407
|
-
$('#btn-ask-forecast').disabled = !
|
|
407
|
+
$('#btn-ask-forecast').disabled = !state.chat.state?.llm || !state.forecast;
|
|
408
408
|
$('#btn-ask-forecast').title = !state.forecast
|
|
409
409
|
? 'Сначала дождитесь расчёта прогноза'
|
|
410
|
-
: !
|
|
411
|
-
? '
|
|
412
|
-
: '
|
|
410
|
+
: !state.chat.state?.llm
|
|
411
|
+
? 'ИИ не подключён (Настройки → Модели ИИ)'
|
|
412
|
+
: 'Начать новый диалог со Стратегом по текущему прогнозу';
|
|
413
413
|
}
|
|
414
414
|
document.querySelectorAll('[data-live-ask]').forEach((b) => {
|
|
415
415
|
b.disabled = !aiReady;
|
|
@@ -866,7 +866,10 @@ $('#run-bar').addEventListener('click', async (e) => {
|
|
|
866
866
|
return;
|
|
867
867
|
}
|
|
868
868
|
if (e.target.closest('#btn-ask-run')) {
|
|
869
|
-
sendChat(
|
|
869
|
+
sendChat(
|
|
870
|
+
`Разбери исследование №${r.id} через analyze_run: за счёт чего получился такой результат, какие слабые места и что стоит проверить следующим шагом?`,
|
|
871
|
+
{ newSession: true, title: `Разбор исследования №${r.id}` },
|
|
872
|
+
);
|
|
870
873
|
}
|
|
871
874
|
});
|
|
872
875
|
$('#btn-refresh-forecast')?.addEventListener('click', () => {
|
|
@@ -874,7 +877,10 @@ $('#btn-refresh-forecast')?.addEventListener('click', () => {
|
|
|
874
877
|
loadForecast(false, false);
|
|
875
878
|
});
|
|
876
879
|
$('#btn-ask-forecast').addEventListener('click', () => {
|
|
877
|
-
sendChat(
|
|
880
|
+
sendChat(
|
|
881
|
+
'Посмотри текущий прогноз на ближайшие часы (model_forecast) и свежие новости (get_news): какие акции сейчас выглядят сильнее и слабее рынка и нет ли противоречий с новостями?',
|
|
882
|
+
{ newSession: true, title: 'Прогноз и новости рынка' },
|
|
883
|
+
);
|
|
878
884
|
});
|
|
879
885
|
|
|
880
886
|
function setProgress(pct, text) {
|
|
@@ -1115,18 +1121,58 @@ const TOOL_LABELS = {
|
|
|
1115
1121
|
connect_broker_account: 'Подключение брокерского счёта',
|
|
1116
1122
|
};
|
|
1117
1123
|
const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
|
|
1124
|
+
// Автопрокрутка ленты чата («прилипание к низу»):
|
|
1125
|
+
// • пока пользователь внизу, новые сообщения и потоковый ответ прокручивают ленту вниз;
|
|
1126
|
+
// • как только пользователь сам прокрутил вверх (колесо, тач, клавиши, полоса прокрутки) — лента его не дёргает;
|
|
1127
|
+
// • прилипание возвращается, когда пользователь сам дошёл до низа, нажал «↓» или отправил новый запрос.
|
|
1118
1128
|
const feed = $('#feed');
|
|
1119
|
-
const nearBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
|
|
1120
|
-
const scrollDown = (force) => { if (force || nearBottom()) requestAnimationFrame(() => { feed.scrollTop = feed.scrollHeight; }); };
|
|
1121
1129
|
const feedDown = $('#feed-down');
|
|
1130
|
+
const BOTTOM_EPS = 24;
|
|
1131
|
+
const distFromBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight;
|
|
1132
|
+
let stickToBottom = true;
|
|
1133
|
+
let lastFeedTop = 0;
|
|
1134
|
+
let feedScrollRaf = 0;
|
|
1122
1135
|
let unseen = 0;
|
|
1123
1136
|
const updateDown = () => {
|
|
1124
|
-
|
|
1125
|
-
feedDown.
|
|
1137
|
+
const show = !stickToBottom && distFromBottom() > BOTTOM_EPS;
|
|
1138
|
+
feedDown.hidden = !show;
|
|
1139
|
+
feedDown.textContent = unseen ? `↓ ${unseen} нов.` : '↓';
|
|
1126
1140
|
};
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1141
|
+
const scrollDown = (force = false, smooth = false) => {
|
|
1142
|
+
if (force) stickToBottom = true;
|
|
1143
|
+
if (!stickToBottom) { updateDown(); return; }
|
|
1144
|
+
cancelAnimationFrame(feedScrollRaf);
|
|
1145
|
+
feedScrollRaf = requestAnimationFrame(() => {
|
|
1146
|
+
if (!stickToBottom) return;
|
|
1147
|
+
if (smooth) feed.scrollTo({ top: feed.scrollHeight, behavior: 'smooth' });
|
|
1148
|
+
else feed.scrollTop = feed.scrollHeight;
|
|
1149
|
+
lastFeedTop = feed.scrollTop;
|
|
1150
|
+
unseen = 0;
|
|
1151
|
+
updateDown();
|
|
1152
|
+
});
|
|
1153
|
+
};
|
|
1154
|
+
const toBottom = (smooth = false) => { unseen = 0; scrollDown(true, smooth); };
|
|
1155
|
+
const releaseStick = () => { if (distFromBottom() > 2) { stickToBottom = false; updateDown(); } };
|
|
1156
|
+
feed.addEventListener('wheel', (e) => {
|
|
1157
|
+
if (e.deltaY >= 0) return;
|
|
1158
|
+
// прокрутка внутри блока размышлений или таблицы, которые сами ещё листаются вверх, ленту не трогает
|
|
1159
|
+
const inner = e.target.closest?.('.think-body, .md-c pre, .md-c .tbl-wrap');
|
|
1160
|
+
if (inner && inner.scrollTop > 0) return;
|
|
1161
|
+
stickToBottom = false;
|
|
1162
|
+
updateDown();
|
|
1163
|
+
}, { passive: true });
|
|
1164
|
+
feed.addEventListener('touchmove', releaseStick, { passive: true });
|
|
1165
|
+
feed.addEventListener('keydown', (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) { stickToBottom = false; updateDown(); } });
|
|
1166
|
+
feed.addEventListener('scroll', () => {
|
|
1167
|
+
const top = feed.scrollTop;
|
|
1168
|
+
// прокрутка вверх (в том числе мышью за полосу прокрутки) — пользователь читает историю
|
|
1169
|
+
if (top < lastFeedTop - 4 && distFromBottom() > BOTTOM_EPS) stickToBottom = false;
|
|
1170
|
+
// пользователь сам вернулся вниз — снова следим за новыми сообщениями
|
|
1171
|
+
if (distFromBottom() <= BOTTOM_EPS) { stickToBottom = true; unseen = 0; }
|
|
1172
|
+
lastFeedTop = top;
|
|
1173
|
+
updateDown();
|
|
1174
|
+
}, { passive: true });
|
|
1175
|
+
feedDown.addEventListener('click', () => toBottom(true));
|
|
1130
1176
|
const msgEls = new Map();
|
|
1131
1177
|
const liveEls = new Map();
|
|
1132
1178
|
|
|
@@ -1199,7 +1245,7 @@ function renderMsg(m) {
|
|
|
1199
1245
|
function upsertMsg(m) {
|
|
1200
1246
|
state.chat.msgs.set(m.id, m);
|
|
1201
1247
|
const old = msgEls.get(m.id);
|
|
1202
|
-
const stick =
|
|
1248
|
+
const stick = stickToBottom;
|
|
1203
1249
|
const el = renderMsg(m);
|
|
1204
1250
|
if (!el) { old?.remove(); msgEls.delete(m.id); return; }
|
|
1205
1251
|
if (old) {
|
|
@@ -1232,7 +1278,7 @@ function onDelta(d) {
|
|
|
1232
1278
|
|
|
1233
1279
|
function liveLine(key, html) {
|
|
1234
1280
|
let el = liveEls.get(key);
|
|
1235
|
-
const stick =
|
|
1281
|
+
const stick = stickToBottom;
|
|
1236
1282
|
if (!el) { el = document.createElement('div'); el.className = 'live'; liveEls.set(key, el); feed.append(el); }
|
|
1237
1283
|
el.innerHTML = html;
|
|
1238
1284
|
if (stick) scrollDown(true);
|
|
@@ -1343,12 +1389,15 @@ function closeModeMenu() {
|
|
|
1343
1389
|
if (m) m.hidden = true;
|
|
1344
1390
|
}
|
|
1345
1391
|
|
|
1346
|
-
async function sendChat(text) {
|
|
1392
|
+
async function sendChat(text, { newSession = false, title = null } = {}) {
|
|
1347
1393
|
if (speechRec && speechActive) stopVoiceInput(false);
|
|
1348
1394
|
text = (text ?? $('#chat-input').value).trim();
|
|
1349
1395
|
if (!text) return;
|
|
1396
|
+
if (document.body.classList.contains('chat-closed')) setChatClosed(false);
|
|
1397
|
+
// новый запрос — лента снова «прилипает» к низу и показывает вопрос и потоковый ответ
|
|
1398
|
+
toBottom(true);
|
|
1350
1399
|
try {
|
|
1351
|
-
await post('/api/chat/send', { text, mode: 'advisor' });
|
|
1400
|
+
await post('/api/chat/send', { text, mode: 'advisor', newSession: Boolean(newSession), title: title || undefined });
|
|
1352
1401
|
$('#chat-input').value = '';
|
|
1353
1402
|
resizeChatInput();
|
|
1354
1403
|
toBottom();
|
|
@@ -1357,7 +1406,83 @@ async function sendChat(text) {
|
|
|
1357
1406
|
|
|
1358
1407
|
$('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!state.chat.state?.busy) sendChat(); } });
|
|
1359
1408
|
$('#chat-stop').addEventListener('click', () => post('/api/chat/stop'));
|
|
1360
|
-
|
|
1409
|
+
|
|
1410
|
+
// ---------- архив сессий со Стратегом (.md) ----------
|
|
1411
|
+
let currentArchiveView = null; // { id, title, fileName, markdown }
|
|
1412
|
+
|
|
1413
|
+
async function openChatArchiveList() {
|
|
1414
|
+
currentArchiveView = null;
|
|
1415
|
+
$('#archive-title').textContent = 'Архив сессий со Стратегом (.md)';
|
|
1416
|
+
$('#archive-back').hidden = true;
|
|
1417
|
+
$('#archive-open-chat').hidden = true;
|
|
1418
|
+
$('#archive-download-md').hidden = true;
|
|
1419
|
+
$('#archive-body').textContent = 'Загрузка…';
|
|
1420
|
+
$('#archive-modal').hidden = false;
|
|
1421
|
+
try {
|
|
1422
|
+
const { sessions } = await api('/api/chat/archive');
|
|
1423
|
+
if (!sessions?.length) {
|
|
1424
|
+
$('#archive-body').innerHTML = '<p class="muted">Архивных сессий пока нет. Каждая беседа со Стратегом автоматически сохраняется в архив в формате Markdown (.md) с датой, временем и темой запроса.</p>';
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
$('#archive-body').innerHTML = `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Тема сессии</th><th>Файл (.md)</th><th class="num">Сообщ.</th><th style="text-align:right">Действия</th></tr></thead><tbody>${sessions.map((s) => `<tr>
|
|
1428
|
+
<td class="small" style="white-space:nowrap">${esc(s.timeLabel)}</td>
|
|
1429
|
+
<td><b>${esc(s.title)}</b>${s.active ? ' <span class="tag ok">текущая</span>' : ''}</td>
|
|
1430
|
+
<td class="small muted"><code>${esc(s.fileName)}</code></td>
|
|
1431
|
+
<td class="num small">${s.msgCount}</td>
|
|
1432
|
+
<td style="text-align:right;white-space:nowrap">
|
|
1433
|
+
<button type="button" class="small" data-arch-view="${s.id}">📖 Просмотр .md</button>
|
|
1434
|
+
<button type="button" class="primary small" data-arch-open="${s.id}">В чат</button>
|
|
1435
|
+
</td>
|
|
1436
|
+
</tr>`).join('')}</tbody></table></div>`;
|
|
1437
|
+
} catch (e) {
|
|
1438
|
+
$('#archive-body').textContent = e.message;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function viewArchivedMarkdown(id) {
|
|
1443
|
+
$('#archive-body').textContent = 'Загрузка…';
|
|
1444
|
+
try {
|
|
1445
|
+
const info = await api(`/api/chat/archive/${id}/md`);
|
|
1446
|
+
currentArchiveView = info;
|
|
1447
|
+
$('#archive-title').textContent = `${info.title} · ${info.fileName}`;
|
|
1448
|
+
$('#archive-back').hidden = false;
|
|
1449
|
+
$('#archive-open-chat').hidden = false;
|
|
1450
|
+
$('#archive-download-md').hidden = false;
|
|
1451
|
+
$('#archive-body').innerHTML = `<div class="md">${md(info.markdown)}</div>`;
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
$('#archive-body').textContent = e.message;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
$('#btn-chat-archive')?.addEventListener('click', openChatArchiveList);
|
|
1458
|
+
$('#archive-back')?.addEventListener('click', openChatArchiveList);
|
|
1459
|
+
$('#archive-close')?.addEventListener('click', () => { $('#archive-modal').hidden = true; });
|
|
1460
|
+
$('#archive-modal')?.addEventListener('click', async (e) => {
|
|
1461
|
+
if (e.target.id === 'archive-modal') { $('#archive-modal').hidden = true; return; }
|
|
1462
|
+
const vBtn = e.target.closest('button[data-arch-view]');
|
|
1463
|
+
if (vBtn) { await viewArchivedMarkdown(Number(vBtn.dataset.archView)); return; }
|
|
1464
|
+
const oBtn = e.target.closest('button[data-arch-open]');
|
|
1465
|
+
if (oBtn) {
|
|
1466
|
+
await post(`/api/chat/archive/${Number(oBtn.dataset.archOpen)}/open`);
|
|
1467
|
+
$('#archive-modal').hidden = true;
|
|
1468
|
+
if (document.body.classList.contains('chat-closed')) setChatClosed(false);
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
$('#archive-open-chat')?.addEventListener('click', async () => {
|
|
1472
|
+
if (!currentArchiveView?.id) return;
|
|
1473
|
+
await post(`/api/chat/archive/${currentArchiveView.id}/open`);
|
|
1474
|
+
$('#archive-modal').hidden = true;
|
|
1475
|
+
if (document.body.classList.contains('chat-closed')) setChatClosed(false);
|
|
1476
|
+
});
|
|
1477
|
+
$('#archive-download-md')?.addEventListener('click', () => {
|
|
1478
|
+
if (!currentArchiveView?.markdown) return;
|
|
1479
|
+
const blob = new Blob([currentArchiveView.markdown], { type: 'text/markdown;charset=utf-8' });
|
|
1480
|
+
const a = document.createElement('a');
|
|
1481
|
+
a.href = URL.createObjectURL(blob);
|
|
1482
|
+
a.download = currentArchiveView.fileName || `session_${currentArchiveView.id}.md`;
|
|
1483
|
+
a.click();
|
|
1484
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
|
1485
|
+
});
|
|
1361
1486
|
|
|
1362
1487
|
// ---------- голосовой ввод (Web Speech API) + звуковой сигнал БИП ----------
|
|
1363
1488
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
@@ -2459,7 +2584,7 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
|
|
|
2459
2584
|
} catch { /* ignore */ }
|
|
2460
2585
|
}
|
|
2461
2586
|
toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
|
|
2462
|
-
await sendChat(promptText);
|
|
2587
|
+
await sendChat(promptText, { newSession: true, title: `Подключение счёта ${brkName}` });
|
|
2463
2588
|
});
|
|
2464
2589
|
|
|
2465
2590
|
function updateBrokerSaveBtnState() {
|
|
@@ -2896,7 +3021,21 @@ function connectWs() {
|
|
|
2896
3021
|
if (!m.state.busy) state.chat.stream.clear();
|
|
2897
3022
|
renderChatState();
|
|
2898
3023
|
break;
|
|
2899
|
-
case 'chat:reset':
|
|
3024
|
+
case 'chat:reset':
|
|
3025
|
+
feed.querySelectorAll('.msg, .ev').forEach((e) => { if (!e.classList.contains('live')) e.remove(); });
|
|
3026
|
+
msgEls.clear();
|
|
3027
|
+
state.chat.msgs.clear();
|
|
3028
|
+
state.chat.stream.clear();
|
|
3029
|
+
if (m.state) state.chat.state = m.state;
|
|
3030
|
+
if (Array.isArray(m.messages)) {
|
|
3031
|
+
for (const msg of m.messages) upsertMsg(msg);
|
|
3032
|
+
renderChatState();
|
|
3033
|
+
toBottom();
|
|
3034
|
+
} else {
|
|
3035
|
+
renderChatState();
|
|
3036
|
+
loadChat().catch(() => {});
|
|
3037
|
+
}
|
|
3038
|
+
break;
|
|
2900
3039
|
case 'llm:changed':
|
|
2901
3040
|
state.config.llm = m.llm;
|
|
2902
3041
|
showLlmStatus();
|
|
@@ -3634,7 +3773,10 @@ $('#btn-stock-ask')?.addEventListener('click', () => {
|
|
|
3634
3773
|
if (!currentStockData?.symbol) return;
|
|
3635
3774
|
const sym = currentStockData.symbol;
|
|
3636
3775
|
$('#stock-modal').hidden = true;
|
|
3637
|
-
sendChat(
|
|
3776
|
+
sendChat(
|
|
3777
|
+
`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`,
|
|
3778
|
+
{ newSession: true, title: `Досье акции ${sym} (${currentStockData.name || sym})` },
|
|
3779
|
+
);
|
|
3638
3780
|
});
|
|
3639
3781
|
|
|
3640
3782
|
// Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
|
|
@@ -3650,6 +3792,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
3650
3792
|
if (e.key === 'Escape') {
|
|
3651
3793
|
$('#help').hidden = true;
|
|
3652
3794
|
if ($('#stock-modal')) $('#stock-modal').hidden = true;
|
|
3795
|
+
if ($('#events-modal')) $('#events-modal').hidden = true;
|
|
3796
|
+
if ($('#archive-modal')) $('#archive-modal').hidden = true;
|
|
3653
3797
|
closeModelMenu();
|
|
3654
3798
|
closeModeMenu();
|
|
3655
3799
|
}
|
package/public/index.html
CHANGED
|
@@ -27,13 +27,6 @@
|
|
|
27
27
|
|
|
28
28
|
<main id="main">
|
|
29
29
|
<section class="chat">
|
|
30
|
-
<div class="chat-head">
|
|
31
|
-
<b>Стратег</b>
|
|
32
|
-
<span id="chat-status" class="muted small"></span>
|
|
33
|
-
<span class="grow"></span>
|
|
34
|
-
<button id="chat-new" class="icon small" title="Начать новый диалог со Стратегом">+ Новый диалог</button>
|
|
35
|
-
<button id="chat-hide" class="icon small" title="Свернуть панель чата со Стратегом">◀</button>
|
|
36
|
-
</div>
|
|
37
30
|
<div class="feed-wrap">
|
|
38
31
|
<div id="feed" class="feed"></div>
|
|
39
32
|
<button id="feed-down" class="feed-down" hidden title="К новым сообщениям"></button>
|
|
@@ -43,7 +36,12 @@
|
|
|
43
36
|
<div class="prompt-box" id="prompt-box">
|
|
44
37
|
<textarea id="chat-input" rows="2" placeholder="Спросите Стратега о прогнозе, портфеле или настройках счёта…"></textarea>
|
|
45
38
|
<div class="prompt-bar">
|
|
46
|
-
<div class="prompt-left"
|
|
39
|
+
<div class="prompt-left">
|
|
40
|
+
<button type="button" id="btn-chat-archive" class="ghost-select model-btn" title="Архив предыдущих сессий со Стратегом (.md)">
|
|
41
|
+
<span>🗂 Архив</span>
|
|
42
|
+
</button>
|
|
43
|
+
<span id="chat-status" class="muted small"></span>
|
|
44
|
+
</div>
|
|
47
45
|
<div class="prompt-right">
|
|
48
46
|
<div class="model-picker" id="model-picker">
|
|
49
47
|
<button type="button" id="chat-model-btn" class="ghost-select model-btn" title="Модель ИИ (настраивается в «Настройки → Модели ИИ»)">
|
|
@@ -315,6 +313,21 @@
|
|
|
315
313
|
</div>
|
|
316
314
|
</div>
|
|
317
315
|
|
|
316
|
+
<!-- Архив сессий со Стратегом (.md) -->
|
|
317
|
+
<div id="archive-modal" class="modal" hidden>
|
|
318
|
+
<div class="modal-box stock-modal-box" role="dialog" aria-labelledby="archive-title">
|
|
319
|
+
<div class="modal-head" style="gap:10px;flex-wrap:wrap">
|
|
320
|
+
<b id="archive-title">Архив сессий со Стратегом</b>
|
|
321
|
+
<span class="grow"></span>
|
|
322
|
+
<button id="archive-back" type="button" class="small" hidden>← Все сессии</button>
|
|
323
|
+
<button id="archive-open-chat" type="button" class="primary small" hidden title="Загрузить эту сессию в панель чата и продолжить диалог">Открыть в чате</button>
|
|
324
|
+
<button id="archive-download-md" type="button" class="small" hidden title="Скачать файл сессии в формате Markdown (.md)">⬇ Скачать .md</button>
|
|
325
|
+
<button id="archive-close" class="icon" title="Закрыть (Esc)" aria-label="Закрыть">✕</button>
|
|
326
|
+
</div>
|
|
327
|
+
<div id="archive-body" class="stock-modal-body">Загрузка…</div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
318
331
|
<!-- Модальное окно досье акции (прогнозы, соседи по карте, график, дивиденды, бизнес-профиль, сделки, новости) -->
|
|
319
332
|
<div id="stock-modal" class="modal" hidden>
|
|
320
333
|
<div class="modal-box stock-modal-box">
|
package/public/styles.css
CHANGED
|
@@ -96,17 +96,26 @@ body.dragging iframe, body.dragging canvas { pointer-events: none; }
|
|
|
96
96
|
.feed, .tab, .think-body, .modal .md { scrollbar-width: thin; scrollbar-color: #2a3140 transparent; }
|
|
97
97
|
|
|
98
98
|
/* ---------- чат ---------- */
|
|
99
|
-
.chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
99
|
+
.chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
|
100
100
|
.chat-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
|
101
101
|
.chat-head select { font-size: 12.5px; padding: 3px 6px; }
|
|
102
102
|
.chat-head label { font-size: 12px; color: var(--muted); }
|
|
103
103
|
.chat-head label input { width: 46px; padding: 2px 4px; }
|
|
104
104
|
.feed-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
|
|
105
|
-
|
|
106
|
-
.feed
|
|
107
|
-
.
|
|
105
|
+
/* Лента чата: только вертикальная прокрутка; широкое содержимое (таблицы, код) листается внутри своего блока */
|
|
106
|
+
.feed { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; min-width: 0; }
|
|
107
|
+
.feed > * { min-width: 0; max-width: 100%; }
|
|
108
|
+
.feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; z-index: 2; }
|
|
109
|
+
.msg { max-width: 92%; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
|
108
110
|
.msg.user { align-self: flex-end; background: #1d3359; border-radius: 12px 12px 3px 12px; padding: 7px 12px; white-space: pre-wrap; }
|
|
109
|
-
.msg.ai { align-self: stretch; }
|
|
111
|
+
.msg.ai { align-self: stretch; max-width: 100%; }
|
|
112
|
+
.md-c { min-width: 0; max-width: 100%; }
|
|
113
|
+
.md-c pre { max-width: 100%; overflow-x: auto; white-space: pre; background: var(--panel); padding: 8px 10px; border-radius: 6px; }
|
|
114
|
+
.md-c pre code { background: none; padding: 0; }
|
|
115
|
+
.md-c .tbl-wrap { max-width: 100%; overflow-x: auto; margin: 6px 0; }
|
|
116
|
+
.md-c .tbl-wrap table { margin: 0; }
|
|
117
|
+
.md-c img { max-width: 100%; height: auto; }
|
|
118
|
+
.ev, .live, .act { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
|
|
110
119
|
.md-c > :first-child { margin-top: 0; }
|
|
111
120
|
.md-c > :last-child { margin-bottom: 0; }
|
|
112
121
|
.md-c p { margin: 6px 0; }
|
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/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
|
}
|