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 LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'};
|
||||
// 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) {
|
||||
// Thinking first, folded: ::думаю:: … ::ответ:: (or <think>), open while
|
||||
// it is going on.
|
||||
let html = '', n = 0;
|
||||
let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body');
|
||||
if (!body) {
|
||||
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);
|
||||
if (th) {
|
||||
const going = !th[2];
|
||||
html += '<details class="ai-think"' + (going ? ' open' : '') + '><summary>' + (going ? 'Думает…' : 'Размышления') + '</summary><div>' +
|
||||
esc(th[1].trim()).replace(/\n/g, '<br>') + '</div></details>';
|
||||
const going = !th[2] && !done;
|
||||
if (!el._t0) el._t0 = Date.now();
|
||||
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);
|
||||
}
|
||||
let html = '', n = 0;
|
||||
const parts = text.split(/```/);
|
||||
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 head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, '');
|
||||
const closed = i < parts.length - 1 || done;
|
||||
@@ -320,7 +335,7 @@
|
||||
(code || !act ? '<pre>' + esc(code) + '</pre>' : '') +
|
||||
(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;
|
||||
}
|
||||
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; }
|
||||
.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-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. */
|
||||
body:not(.touch) .ai-head { cursor:grab; 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. */
|
||||
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; } }
|
||||
/* 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;
|
||||
}
|
||||
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;
|
||||
})(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.
|
||||
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