feat: the assistant's answers in Markdown; thinking shows «Думаю…», then «Думал N с», opening smoothly by its arrow

This commit is contained in:
redtea committed 2026-10-16 09:33:23 +03:00
1 parent 4b7220fffb
commit 4da1cc4200
4 files changed
+103 -12

No files matched your search

+23 -8
View File
@@ -298,20 +298,35 @@
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, ''); const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'}; const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'};
// render: text with ``` blocks; action blocks get their button. // render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The
// thinking block is made once and only its words change, so it stays open
// or shut as the user left it while the answer streams.
const CHEV = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
function render(el, text, done) { function render(el, text, done) {
// Thinking first, folded: ::думаю:: … ::ответ:: (or <think>), open while let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body');
// it is going on. if (!body) {
let html = '', n = 0; el.innerHTML = '<div class="ai-think" hidden><button type="button" class="ai-think-h">' + CHEV + '<span></span></button>' +
'<div class="ai-think-b"><div class="ai-think-in"></div></div></div><div class="ai-body"></div>';
think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body');
think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open');
}
const th = /^\s*(?:<think>|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text); const th = /^\s*(?:<think>|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text);
if (th) { if (th) {
const going = !th[2]; const going = !th[2] && !done;
html += '<details class="ai-think"' + (going ? ' open' : '') + '><summary>' + (going ? 'Думает…' : 'Размышления') + '</summary><div>' + if (!el._t0) el._t0 = Date.now();
esc(th[1].trim()).replace(/\n/g, '<br>') + '</div></details>'; if (!going && !el._t1) el._t1 = Date.now();
think.hidden = false;
think.classList.toggle('going', going);
think.querySelector('.ai-think-h span').textContent = going ? 'Думаю…'
: 'Думал ' + Math.max(1, Math.round((el._t1 - el._t0) / 1000)) + ' с';
think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim());
text = text.slice(th[0].length); text = text.slice(th[0].length);
} }
let html = '', n = 0;
const parts = text.split(/```/); const parts = text.split(/```/);
parts.forEach((part, i) => { parts.forEach((part, i) => {
if (i % 2 === 0) { html += '<div class="ai-text">' + esc(part).replace(/\n/g, '<br>') + '</div>'; return; } if (i % 2 === 0) { if (part.trim()) html += '<div class="ai-text">' + WSUtil.md(part) + '</div>'; return; }
const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part); const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part);
const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, ''); const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, '');
const closed = i < parts.length - 1 || done; const closed = i < parts.length - 1 || done;
@@ -320,7 +335,7 @@
(code || !act ? '<pre>' + esc(code) + '</pre>' : '') + (code || !act ? '<pre>' + esc(code) + '</pre>' : '') +
(act ? '<button type="button" class="btn small ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button>' : '') + '</div>'; (act ? '<button type="button" class="btn small ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button>' : '') + '</div>';
}); });
el.innerHTML = html; body.innerHTML = html;
el.dataset.text = text; el.dataset.text = text;
} }
function add(role, text) { function add(role, text) {
+23 -3
View File
@@ -887,9 +887,6 @@ dialog.uidlg { width:auto; min-width:min(320px, 92vw); max-width:min(560px, 92vw
background:transparent; color:var(--muted); border:1px solid var(--border); transition:background .18s, color .18s, border-color .18s; } background:transparent; color:var(--muted); border:1px solid var(--border); transition:background .18s, color .18s, border-color .18s; }
.ai-pill:hover { color:var(--text); border-color:#3a4554; } .ai-pill:hover { color:var(--text); border-color:#3a4554; }
.ai-pill[aria-pressed="true"] { background:rgba(91,140,255,.16); color:#9db8ff; border-color:rgba(91,140,255,.55); } .ai-pill[aria-pressed="true"] { background:rgba(91,140,255,.16); color:#9db8ff; border-color:rgba(91,140,255,.55); }
.ai-think { margin:0 0 6px; font-size:12.5px; color:var(--muted); }
.ai-think summary { cursor:pointer; }
.ai-think > div { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:200px; overflow:auto; }
/* The chat's title moves it on a computer. */ /* The chat's title moves it on a computer. */
body:not(.touch) .ai-head { cursor:grab; user-select:none; } body:not(.touch) .ai-head { cursor:grab; user-select:none; }
body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; } body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; }
@@ -926,3 +923,26 @@ body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
/* Moving between Машины, WorkSpace and Настройки: the page comes in softly. */ /* Moving between Машины, WorkSpace and Настройки: the page comes in softly. */
main#app > .view:not([hidden]) { animation:view-in .22s ease both; } main#app > .view:not([hidden]) { animation:view-in .22s ease both; }
@keyframes view-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } } @keyframes view-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
/* The assistant's answer: Markdown, and its thinking folded above. */
.ai-text p { margin:0 0 6px; } .ai-text p:last-child { margin-bottom:0; }
.ai-text h4 { margin:8px 0 4px; font-size:14px; }
.ai-text ul, .ai-text ol { margin:2px 0 6px; padding-left:20px; }
.ai-text li { margin:2px 0; }
.ai-text code { font:12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:#0c0f13; border:1px solid var(--border); border-radius:5px; padding:0 4px; }
.ai-text blockquote { margin:4px 0; padding-left:10px; border-left:2px solid var(--border); color:var(--muted); }
.ai-text a { color:var(--accent); }
.ai-text hr { border:0; border-top:1px solid var(--border); margin:8px 0; }
.ai-think { margin:0 0 6px; }
.ai-think[hidden] { display:none; }
.ai-think-h { display:inline-flex; align-items:center; gap:5px; padding:2px 4px 2px 0; background:none; border:0; color:var(--muted); font-size:12.5px; cursor:pointer; }
.ai-think-h:hover { color:var(--text); }
.ai-think-h svg { transition:transform .2s ease; }
.ai-think.open .ai-think-h svg { transform:rotate(90deg); }
.ai-think.going .ai-think-h span { background:linear-gradient(90deg, var(--muted) 30%, var(--text) 50%, var(--muted) 70%) 0 0/200% 100%;
-webkit-background-clip:text; background-clip:text; color:transparent; animation:ai-shine 1.4s linear infinite; }
@keyframes ai-shine { to { background-position:-200% 0; } }
.ai-think-b { display:grid; grid-template-rows:0fr; transition:grid-template-rows .22s ease, opacity .2s ease; opacity:0; }
.ai-think.open .ai-think-b { grid-template-rows:1fr; opacity:1; }
.ai-think-in { min-height:0; overflow:hidden; font-size:12px; color:var(--muted); line-height:1.45; }
.ai-think.open .ai-think-in { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:220px; overflow:auto; }
.ai-think-in p { margin:0 0 4px; }
+44 -1
View File
@@ -58,6 +58,49 @@
} }
return out; return out;
} }
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize, scope, parseActions}; // md turns an assistant's text (no ``` blocks - those are drawn apart)
// into HTML: headings, lists, quotes, bold, italic, `code` and http(s)
// links. Everything is escaped first, so nothing else gets through.
const escH = v => String(v).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
function mdInline(t) {
const codes = [];
t = t.replace(/`([^`\n]+)`/g, (_, c) => { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
t = escH(t)
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<i>$2</i>')
.replace(/(^|\W)_([^_\n]+)_(?!\w)/g, '$1<i>$2</i>')
.replace(/\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (_, txt, url) => /^https?:\/\//i.test(url.replace(/&amp;/g, '&'))
? '<a href="' + url + '" target="_blank" rel="noopener">' + txt + '</a>' : txt);
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => '<code>' + escH(codes[+i]) + '</code>');
}
function md(text) {
const out = [];
let para = [], list = null;
const flush = () => {
if (para.length) out.push('<p>' + para.map(mdInline).join('<br>') + '</p>');
para = [];
if (list) { out.push('<' + list.tag + '>' + list.items.map(x => '<li>' + mdInline(x) + '</li>').join('') + '</' + list.tag + '>'); list = null; }
};
for (const line of String(text).split('\n')) {
let m;
if (!line.trim()) { flush(); continue; }
if ((m = /^#{1,6}\s+(.*)$/.exec(line))) { flush(); out.push('<h4>' + mdInline(m[1]) + '</h4>'); continue; }
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); out.push('<hr>'); continue; }
if ((m = /^>\s?(.*)$/.exec(line))) { flush(); out.push('<blockquote>' + mdInline(m[1]) + '</blockquote>'); continue; }
const ul = /^\s*[-*•]\s+(.*)$/.exec(line), ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
if (ul || ol) {
const tag = ul ? 'ul' : 'ol';
if (para.length || (list && list.tag !== tag)) flush();
if (!list) list = {tag, items: []};
list.items.push((ul || ol)[1]);
continue;
}
if (list) flush();
para.push(line);
}
flush();
return out.join('');
}
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize, scope, parseActions, md};
if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api; if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api;
})(this); })(this);
+13
View File
@@ -62,3 +62,16 @@ test('parseActions: run, read, ls and write blocks with or without a machine', (
// A block still being written is not an action yet. // A block still being written is not an action yet.
assert.strictEqual(U.parseActions('```run\nls').length, 0); assert.strictEqual(U.parseActions('```run\nls').length, 0);
}); });
test('md: the common marks, everything else escaped', () => {
assert.strictEqual(U.md('**жирный** и *курсив*, `ls -la <x>`'),
'<p><b>жирный</b> и <i>курсив</i>, <code>ls -la &lt;x&gt;</code></p>');
assert.strictEqual(U.md('# Итог\n- раз\n- два\n\n1. первое\n2. второе'),
'<h4>Итог</h4><ul><li>раз</li><li>два</li></ul><ol><li>первое</li><li>второе</li></ol>');
assert.strictEqual(U.md('[док](https://ex.com/a?b=1&c=2) и [плохо](javascript:alert(1))'),
'<p><a href="https://ex.com/a?b=1&amp;c=2" target="_blank" rel="noopener">док</a> и плохо</p>');
assert.strictEqual(U.md('> цитата\n\nстрока 1\nстрока 2'), '<blockquote>цитата</blockquote><p>строка 1<br>строка 2</p>');
assert.strictEqual(U.md('<script>x</script>'), '<p>&lt;script&gt;x&lt;/script&gt;</p>');
// Inside code nothing is marked up.
assert.strictEqual(U.md('`**a**`'), '<p><code>**a**</code></p>');
});