feat(assistant): стриминг ответов Квантика (фича 1/6)

Ответ модели «печатается» вживую через SSE поверх POST (fetch-stream,
не EventSource). Бэкенд: callLLMStream (stream:true, парсинг SSE upstream) +
callLLMStreamFailover (failover только до первого куска) + endpoint
POST /assistant/ask/stream (события meta|delta|done; быстрые пути FAQ/кэш/мета
отдаются одним done). buildAskMessages выделен из askModel (DRY).
Клиент: LS.assistantAskStream (fetch-stream + парсер SSE). Виджет: send()
стримит дельты как plain-текст с CSS-кареткой, на done — KaTeX-рендер,
источники, ссылки, оценка. Фоллбэк на sendNonStream (старый путь) если
стриминг недоступен/упал до первого куска. Cache-Control: no-transform
отключает буферизацию compression.

Проверено против живого шлюза: 24 дельты, первый текст ~1.3с, 100% русский.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 14:50:11 +03:00
parent 5b4d9324a4
commit 089f93b8ee
4 changed files with 240 additions and 3 deletions
+78
View File
@@ -322,6 +322,10 @@
'.asst-rich .katex-display::-webkit-scrollbar{height:6px;}',
'.asst-rich .katex-display::-webkit-scrollbar-thumb{background:rgba(15,23,42,.18);border-radius:99px;}',
'.asst-rich .katex{max-width:100%;}',
// мигающий курсор во время стриминга ответа (CSS-каретка, без глифа)
'.asst-streaming{white-space:pre-wrap;}',
'.asst-streaming::after{content:"";display:inline-block;width:2px;height:1em;vertical-align:-2px;margin-left:2px;background:#9B5DE5;animation:asst-blink 1s steps(2) infinite;}',
'@keyframes asst-blink{50%{opacity:0;}}',
'.asst-md-h{font-weight:800;color:#0F172A;margin:6px 0 2px;}',
'.asst-chat{max-height:46vh;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:8px;}',
'.asst-chat:empty{display:none;}',
@@ -604,6 +608,80 @@
});
}
function send(q, context, chatEl, mode) {
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);
// стриминг недоступен (старый кэш api.js / нет ReadableStream) — обычный путь
if (!LS.assistantAskStream || typeof ReadableStream === 'undefined') return sendNonStream(q, context, chatEl, mode);
var history = _chat.slice(-6);
_chat.push({ role: 'user', content: q });
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight;
var searchP = (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; });
var meta = { answers: [], sources: [] }, full = '', msgD = null, richEl = null, streamed = false, finalized = false;
function ensureMsg() {
if (msgD) return;
if (ph.parentNode) ph.remove();
msgD = msgEl('assistant'); msgD.innerHTML = '<div class="asst-rich asst-streaming"></div>';
richEl = msgD.querySelector('.asst-rich'); chatEl.appendChild(msgD);
}
function finalize(done) {
if (finalized) return; finalized = true;
done = done || {};
var src = done.source;
if ((src === 'limit' || src === 'error') && !full) {
_chat.pop();
if (msgD) msgD.remove(); if (ph.parentNode) ph.remove();
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = done.answer || 'Сейчас не получилось. Попробуй ещё раз.';
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; return;
}
var isModel = src === 'model' && (full || done.answer);
searchP.then(function (sres) {
var found = (sres && sres.results) || [];
var ansArr = (done.answers && done.answers.length ? done.answers : meta.answers) || [];
var sources = done.sources || meta.sources || [];
var content = isModel ? (full || done.answer) : ((ansArr[0] && (ansArr[0].q + '\n' + ansArr[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).');
ensureMsg(); richEl.classList.remove('asst-streaming');
_chat.push({ role: 'assistant', content: content });
renderRich(richEl, content);
if (isModel && sources.length) {
var sc = document.createElement('div'); sc.className = 'asst-src';
sc.innerHTML = 'Источник: ' + sources.map(function (s) { return '<a href="' + esc(safeUrl(srcUrl(s))) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).join('; ');
chatEl.appendChild(sc);
}
var links = '';
if (!isModel && ansArr.length) links += ansArr.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(safeUrl(a.url)) + '">' + esc(a.q) + '</a>'; }).join(' · ');
if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(safeUrl(f.url)) + '">' + esc(f.title || '…') + '</a>'; }).join(' · ');
if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); }
if (isModel) {
var fb = document.createElement('div'); fb.className = 'asst-fb';
fb.innerHTML = '<button data-r="1" title="Полезно">' + FB_UP + '</button><button data-r="-1" title="Не помогло">' + FB_DOWN + '</button>';
fb.querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () { if (fb.dataset.done) return; fb.dataset.done = '1'; b.classList.add('on'); try { LS.assistantFeedback(Number(b.getAttribute('data-r')), q); } catch (e) {} });
});
chatEl.appendChild(fb);
}
chatEl.scrollTop = chatEl.scrollHeight;
});
}
LS.assistantAskStream(q, context, history, mode, {
onMeta: function (m) { if (m.answers) meta.answers = m.answers; if (m.sources) meta.sources = m.sources; },
onDelta: function (t) { streamed = true; ensureMsg(); full += t; richEl.textContent = full; chatEl.scrollTop = chatEl.scrollHeight; },
onDone: function (o) { finalize(o); },
}).then(function () { if (!finalized) finalize({ source: full ? 'model' : 'faq' }); })
.catch(function () {
if (finalized) return;
if (!streamed) { if (ph.parentNode) ph.remove(); _chat.pop(); sendNonStream(q, context, chatEl, mode); }
else finalize({ source: 'model' });
});
}
function sendNonStream(q, context, chatEl, mode) {
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);