feat: the assistant's answers in Markdown; thinking shows «Думаю…», then «Думал N с», opening smoothly by its arrow
This commit is contained in:
1 parent
4b7220fffb
commit
4da1cc4200
4 files changed
+103
-12
No files matched your search
@@ -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) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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(/&/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);
|
||||||
@@ -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 <x></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&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><script>x</script></p>');
|
||||||
|
// Inside code nothing is marked up.
|
||||||
|
assert.strictEqual(U.md('`**a**`'), '<p><code>**a**</code></p>');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user