neuroalpha 0.5.9 → 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 +116 -16
- package/public/index.html +21 -8
- 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
|
@@ -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,15 @@ 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
|
+
if (document.body.classList.contains('chat-closed')) setChatClosed(false);
|
|
1392
1397
|
// новый запрос — лента снова «прилипает» к низу и показывает вопрос и потоковый ответ
|
|
1393
1398
|
toBottom(true);
|
|
1394
1399
|
try {
|
|
1395
|
-
await post('/api/chat/send', { text, mode: 'advisor' });
|
|
1400
|
+
await post('/api/chat/send', { text, mode: 'advisor', newSession: Boolean(newSession), title: title || undefined });
|
|
1396
1401
|
$('#chat-input').value = '';
|
|
1397
1402
|
resizeChatInput();
|
|
1398
1403
|
toBottom();
|
|
@@ -1401,7 +1406,83 @@ async function sendChat(text) {
|
|
|
1401
1406
|
|
|
1402
1407
|
$('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!state.chat.state?.busy) sendChat(); } });
|
|
1403
1408
|
$('#chat-stop').addEventListener('click', () => post('/api/chat/stop'));
|
|
1404
|
-
|
|
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
|
+
});
|
|
1405
1486
|
|
|
1406
1487
|
// ---------- голосовой ввод (Web Speech API) + звуковой сигнал БИП ----------
|
|
1407
1488
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
@@ -2503,7 +2584,7 @@ $('#btn-broker-ai-help')?.addEventListener('click', async () => {
|
|
|
2503
2584
|
} catch { /* ignore */ }
|
|
2504
2585
|
}
|
|
2505
2586
|
toast(`Стратег подключается к настройке счёта «${brkName}» — смотрите панель чата слева`);
|
|
2506
|
-
await sendChat(promptText);
|
|
2587
|
+
await sendChat(promptText, { newSession: true, title: `Подключение счёта ${brkName}` });
|
|
2507
2588
|
});
|
|
2508
2589
|
|
|
2509
2590
|
function updateBrokerSaveBtnState() {
|
|
@@ -2940,7 +3021,21 @@ function connectWs() {
|
|
|
2940
3021
|
if (!m.state.busy) state.chat.stream.clear();
|
|
2941
3022
|
renderChatState();
|
|
2942
3023
|
break;
|
|
2943
|
-
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;
|
|
2944
3039
|
case 'llm:changed':
|
|
2945
3040
|
state.config.llm = m.llm;
|
|
2946
3041
|
showLlmStatus();
|
|
@@ -3678,7 +3773,10 @@ $('#btn-stock-ask')?.addEventListener('click', () => {
|
|
|
3678
3773
|
if (!currentStockData?.symbol) return;
|
|
3679
3774
|
const sym = currentStockData.symbol;
|
|
3680
3775
|
$('#stock-modal').hidden = true;
|
|
3681
|
-
sendChat(
|
|
3776
|
+
sendChat(
|
|
3777
|
+
`Разбери подробно акцию ${sym} (${currentStockData.name}): какой сейчас по ней живой прогноз (model_forecast), кто из соседей по карте рынка на неё влияет (market_neighbors), какие свежие новости (get_news) и есть ли по ней сделки на моём счёте?`,
|
|
3778
|
+
{ newSession: true, title: `Досье акции ${sym} (${currentStockData.name || sym})` },
|
|
3779
|
+
);
|
|
3682
3780
|
});
|
|
3683
3781
|
|
|
3684
3782
|
// Глобальный обработчик клика по любой метке акции [data-sym] в любом месте интерфейса
|
|
@@ -3694,6 +3792,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
3694
3792
|
if (e.key === 'Escape') {
|
|
3695
3793
|
$('#help').hidden = true;
|
|
3696
3794
|
if ($('#stock-modal')) $('#stock-modal').hidden = true;
|
|
3795
|
+
if ($('#events-modal')) $('#events-modal').hidden = true;
|
|
3796
|
+
if ($('#archive-modal')) $('#archive-modal').hidden = true;
|
|
3697
3797
|
closeModelMenu();
|
|
3698
3798
|
closeModeMenu();
|
|
3699
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/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
|
}
|