neuroalpha 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -72,7 +72,9 @@ const md = (s) => {
72
72
  ? `${p1}<span class="sym-link" data-sym="${sym}" title="Открыть досье акции ${sym}"><b>${sym}</b></span>`
73
73
  : m
74
74
  ));
75
- return DOMPurify.sanitize(withSyms, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
75
+ // широкие таблицы листаются внутри своей обёртки, а не растягивают ленту чата
76
+ const withWraps = withSyms.replace(/<table>/g, '<div class="tbl-wrap"><table>').replace(/<\/table>/g, '</table></div>');
77
+ return DOMPurify.sanitize(withWraps, { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select'], FORBID_ATTR: ['style'] });
76
78
  };
77
79
  const chartTime = (ts) => Math.floor((ts + MSK) / 1000);
78
80
  const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
@@ -1115,18 +1117,58 @@ const TOOL_LABELS = {
1115
1117
  connect_broker_account: 'Подключение брокерского счёта',
1116
1118
  };
1117
1119
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1120
+ // Автопрокрутка ленты чата («прилипание к низу»):
1121
+ // • пока пользователь внизу, новые сообщения и потоковый ответ прокручивают ленту вниз;
1122
+ // • как только пользователь сам прокрутил вверх (колесо, тач, клавиши, полоса прокрутки) — лента его не дёргает;
1123
+ // • прилипание возвращается, когда пользователь сам дошёл до низа, нажал «↓» или отправил новый запрос.
1118
1124
  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
1125
  const feedDown = $('#feed-down');
1126
+ const BOTTOM_EPS = 24;
1127
+ const distFromBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight;
1128
+ let stickToBottom = true;
1129
+ let lastFeedTop = 0;
1130
+ let feedScrollRaf = 0;
1122
1131
  let unseen = 0;
1123
1132
  const updateDown = () => {
1124
- feedDown.hidden = !unseen || nearBottom();
1125
- feedDown.textContent = unseen ? `↓ ${unseen}` : '↓';
1133
+ const show = !stickToBottom && distFromBottom() > BOTTOM_EPS;
1134
+ feedDown.hidden = !show;
1135
+ feedDown.textContent = unseen ? `↓ ${unseen} нов.` : '↓';
1126
1136
  };
1127
- const toBottom = () => { unseen = 0; scrollDown(true); updateDown(); };
1128
- feed.addEventListener('scroll', () => { if (nearBottom()) unseen = 0; updateDown(); });
1129
- feedDown.addEventListener('click', toBottom);
1137
+ const scrollDown = (force = false, smooth = false) => {
1138
+ if (force) stickToBottom = true;
1139
+ if (!stickToBottom) { updateDown(); return; }
1140
+ cancelAnimationFrame(feedScrollRaf);
1141
+ feedScrollRaf = requestAnimationFrame(() => {
1142
+ if (!stickToBottom) return;
1143
+ if (smooth) feed.scrollTo({ top: feed.scrollHeight, behavior: 'smooth' });
1144
+ else feed.scrollTop = feed.scrollHeight;
1145
+ lastFeedTop = feed.scrollTop;
1146
+ unseen = 0;
1147
+ updateDown();
1148
+ });
1149
+ };
1150
+ const toBottom = (smooth = false) => { unseen = 0; scrollDown(true, smooth); };
1151
+ const releaseStick = () => { if (distFromBottom() > 2) { stickToBottom = false; updateDown(); } };
1152
+ feed.addEventListener('wheel', (e) => {
1153
+ if (e.deltaY >= 0) return;
1154
+ // прокрутка внутри блока размышлений или таблицы, которые сами ещё листаются вверх, ленту не трогает
1155
+ const inner = e.target.closest?.('.think-body, .md-c pre, .md-c .tbl-wrap');
1156
+ if (inner && inner.scrollTop > 0) return;
1157
+ stickToBottom = false;
1158
+ updateDown();
1159
+ }, { passive: true });
1160
+ feed.addEventListener('touchmove', releaseStick, { passive: true });
1161
+ feed.addEventListener('keydown', (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) { stickToBottom = false; updateDown(); } });
1162
+ feed.addEventListener('scroll', () => {
1163
+ const top = feed.scrollTop;
1164
+ // прокрутка вверх (в том числе мышью за полосу прокрутки) — пользователь читает историю
1165
+ if (top < lastFeedTop - 4 && distFromBottom() > BOTTOM_EPS) stickToBottom = false;
1166
+ // пользователь сам вернулся вниз — снова следим за новыми сообщениями
1167
+ if (distFromBottom() <= BOTTOM_EPS) { stickToBottom = true; unseen = 0; }
1168
+ lastFeedTop = top;
1169
+ updateDown();
1170
+ }, { passive: true });
1171
+ feedDown.addEventListener('click', () => toBottom(true));
1130
1172
  const msgEls = new Map();
1131
1173
  const liveEls = new Map();
1132
1174
 
@@ -1199,7 +1241,7 @@ function renderMsg(m) {
1199
1241
  function upsertMsg(m) {
1200
1242
  state.chat.msgs.set(m.id, m);
1201
1243
  const old = msgEls.get(m.id);
1202
- const stick = nearBottom();
1244
+ const stick = stickToBottom;
1203
1245
  const el = renderMsg(m);
1204
1246
  if (!el) { old?.remove(); msgEls.delete(m.id); return; }
1205
1247
  if (old) {
@@ -1232,7 +1274,7 @@ function onDelta(d) {
1232
1274
 
1233
1275
  function liveLine(key, html) {
1234
1276
  let el = liveEls.get(key);
1235
- const stick = nearBottom();
1277
+ const stick = stickToBottom;
1236
1278
  if (!el) { el = document.createElement('div'); el.className = 'live'; liveEls.set(key, el); feed.append(el); }
1237
1279
  el.innerHTML = html;
1238
1280
  if (stick) scrollDown(true);
@@ -1347,6 +1389,8 @@ async function sendChat(text) {
1347
1389
  if (speechRec && speechActive) stopVoiceInput(false);
1348
1390
  text = (text ?? $('#chat-input').value).trim();
1349
1391
  if (!text) return;
1392
+ // новый запрос — лента снова «прилипает» к низу и показывает вопрос и потоковый ответ
1393
+ toBottom(true);
1350
1394
  try {
1351
1395
  await post('/api/chat/send', { text, mode: 'advisor' });
1352
1396
  $('#chat-input').value = '';
package/public/styles.css CHANGED
@@ -96,17 +96,26 @@ body.dragging iframe, body.dragging canvas { pointer-events: none; }
96
96
  .feed, .tab, .think-body, .modal .md { scrollbar-width: thin; scrollbar-color: #2a3140 transparent; }
97
97
 
98
98
  /* ---------- чат ---------- */
99
- .chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
99
+ .chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
100
100
  .chat-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
101
101
  .chat-head select { font-size: 12.5px; padding: 3px 6px; }
102
102
  .chat-head label { font-size: 12px; color: var(--muted); }
103
103
  .chat-head label input { width: 46px; padding: 2px 4px; }
104
104
  .feed-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
105
- .feed { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
106
- .feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; }
107
- .msg { max-width: 92%; }
105
+ /* Лента чата: только вертикальная прокрутка; широкое содержимое (таблицы, код) листается внутри своего блока */
106
+ .feed { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; min-width: 0; }
107
+ .feed > * { min-width: 0; max-width: 100%; }
108
+ .feed-down { position: absolute; right: 16px; bottom: 10px; font-size: 12px; padding: 2px 12px; border-radius: 999px; background: var(--panel); box-shadow: 0 4px 14px #0008; z-index: 2; }
109
+ .msg { max-width: 92%; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
108
110
  .msg.user { align-self: flex-end; background: #1d3359; border-radius: 12px 12px 3px 12px; padding: 7px 12px; white-space: pre-wrap; }
109
- .msg.ai { align-self: stretch; }
111
+ .msg.ai { align-self: stretch; max-width: 100%; }
112
+ .md-c { min-width: 0; max-width: 100%; }
113
+ .md-c pre { max-width: 100%; overflow-x: auto; white-space: pre; background: var(--panel); padding: 8px 10px; border-radius: 6px; }
114
+ .md-c pre code { background: none; padding: 0; }
115
+ .md-c .tbl-wrap { max-width: 100%; overflow-x: auto; margin: 6px 0; }
116
+ .md-c .tbl-wrap table { margin: 0; }
117
+ .md-c img { max-width: 100%; height: auto; }
118
+ .ev, .live, .act { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
110
119
  .md-c > :first-child { margin-top: 0; }
111
120
  .md-c > :last-child { margin-bottom: 0; }
112
121
  .md-c p { margin: 6px 0; }