From 4da1cc4200c980501501e80d100481f81c5fd2e4 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 09:33:23 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20the=20assistant's=20answers=20in=20Mark?= =?UTF-8?q?down;=20thinking=20shows=20=C2=AB=D0=94=D1=83=D0=BC=D0=B0=D1=8E?= =?UTF-8?q?=E2=80=A6=C2=BB,=20then=20=C2=AB=D0=94=D1=83=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=20N=20=D1=81=C2=BB,=20opening=20smoothly=20by=20its=20arrow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/web/static/ai.js | 31 ++++++++++++---- internal/server/web/static/app.css | 26 +++++++++++-- internal/server/web/static/wsutil.js | 45 ++++++++++++++++++++++- internal/server/web/static/wsutil.test.js | 13 +++++++ 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index f9308e4..62d303c 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -298,20 +298,35 @@ const stripThink = t => t.replace(/^\s*(?:|::думаю::)[\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 = ''; function render(el, text, done) { - // Thinking first, folded: ::думаю:: … ::ответ:: (or ), 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 = '
'; + think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body'); + think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open'); + } const th = /^\s*(?:|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text); if (th) { - const going = !th[2]; - html += '
' + (going ? 'Думает…' : 'Размышления') + '
' + - esc(th[1].trim()).replace(/\n/g, '
') + '
'; + 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 += '
' + esc(part).replace(/\n/g, '
') + '
'; return; } + if (i % 2 === 0) { if (part.trim()) html += '
' + WSUtil.md(part) + '
'; 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 ? '
' + esc(code) + '
' : '') + (act ? '' : '') + ''; }); - el.innerHTML = html; + body.innerHTML = html; el.dataset.text = text; } function add(role, text) { diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index a397011..39ca235 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -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; } diff --git a/internal/server/web/static/wsutil.js b/internal/server/web/static/wsutil.js index 651702a..4eaa4ef 100644 --- a/internal/server/web/static/wsutil.js +++ b/internal/server/web/static/wsutil.js @@ -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, '$1') + .replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1$2') + .replace(/(^|\W)_([^_\n]+)_(?!\w)/g, '$1$2') + .replace(/\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (_, txt, url) => /^https?:\/\//i.test(url.replace(/&/g, '&')) + ? '' + txt + '' : txt); + return t.replace(/\u0000(\d+)\u0000/g, (_, i) => '' + escH(codes[+i]) + ''); + } + function md(text) { + const out = []; + let para = [], list = null; + const flush = () => { + if (para.length) out.push('

' + para.map(mdInline).join('
') + '

'); + para = []; + if (list) { out.push('<' + list.tag + '>' + list.items.map(x => '
  • ' + mdInline(x) + '
  • ').join('') + ''); 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('

    ' + mdInline(m[1]) + '

    '); continue; } + if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); out.push('
    '); continue; } + if ((m = /^>\s?(.*)$/.exec(line))) { flush(); out.push('
    ' + mdInline(m[1]) + '
    '); 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); diff --git a/internal/server/web/static/wsutil.test.js b/internal/server/web/static/wsutil.test.js index 3db7b13..744473a 100644 --- a/internal/server/web/static/wsutil.test.js +++ b/internal/server/web/static/wsutil.test.js @@ -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 `'), + '

    жирный и курсив, ls -la <x>

    '); + assert.strictEqual(U.md('# Итог\n- раз\n- два\n\n1. первое\n2. второе'), + '

    Итог

    • раз
    • два
    1. первое
    2. второе
    '); + assert.strictEqual(U.md('[док](https://ex.com/a?b=1&c=2) и [плохо](javascript:alert(1))'), + '

    док и плохо

    '); + assert.strictEqual(U.md('> цитата\n\nстрока 1\nстрока 2'), '
    цитата

    строка 1
    строка 2

    '); + assert.strictEqual(U.md(''), '

    <script>x</script>

    '); + // Inside code nothing is marked up. + assert.strictEqual(U.md('`**a**`'), '

    **a**

    '); +});