diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 3c4bc12..e11d359 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -356,10 +356,16 @@ } // ask first for what changes something: commands and writes. function needsYes(a) { return a.kind === 'run' || a.kind === 'write'; } - function describe(a) { - const where = a.machine ? a.machine + ': ' : ''; - return a.kind === 'run' ? 'Выполнить на ' + (a.machine || 'активной машине') + ':\n\n' + a.body - : 'Записать файл ' + where + a.path + ' (' + a.body.length + ' символов)?'; + // ask what a command or a write is about to do: what, where, the command + // (or the start of the text) in mono. + function confirmAction(a) { + const m = machineOf(a.machine), where = m ? m.name : (a.machine || 'активная машина'); + if (a.kind === 'run') return UI.confirm('Выполнить команду на {chip}?', {ok: 'Выполнить', chip: where, code: a.body, + note: 'Уйдёт в терминал этой машины с вашими правами.'}); + const lines = a.body.split('\n'); + return UI.confirm('Записать файл {chip}?', {ok: 'Записать', chip: where + ':' + a.path, + code: lines.slice(0, 12).join('\n') + (lines.length > 12 ? '\n…' : ''), + note: 'Файл будет заменён целиком (' + a.body.length + ' символов).'}); } // ---------- messages ---------- @@ -463,6 +469,8 @@ if (res) showResult(res); const el = add('assistant', ''); el.classList.add('wait'); + // While the model starts: a typing mark, gone with the first words. + el.querySelector('.ai-body').innerHTML = ''; ac = new AbortController(); send.innerHTML = STOP; send.title = 'Остановить'; let answer = ''; @@ -521,7 +529,7 @@ // Without «Агент» only searches go on by themselves, with «Поиск» on. if (!agentOn() && !(a.kind === 'search' && searchOn())) break; const b = turn.el.querySelector('.ai-act'); - if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) { + if (needsYes(a) && !await confirmAction(a)) { if (b) { b.disabled = true; b.textContent = 'Отклонено'; } turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.'); continue; diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 8e437f7..45c30df 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -856,7 +856,11 @@ body.touch.term-active .ai-fab { bottom:104px; } .ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; } .ai-msg.user { align-self:flex-end; background:#16324f; white-space:pre-wrap; } .ai-msg.assistant { align-self:flex-start; background:#151a21; border:1px solid var(--border); } -.ai-msg.wait::after { content:'…'; color:var(--muted); } +/* The model starting: three dots rising in turn. */ +.ai-typing { display:inline-flex; gap:4px; padding:4px 2px; } +.ai-typing i { width:6px; height:6px; border-radius:50%; background:var(--muted); animation:ai-typing 1.2s ease-in-out infinite; } +.ai-typing i:nth-child(2) { animation-delay:.15s; } .ai-typing i:nth-child(3) { animation-delay:.3s; } +@keyframes ai-typing { 0%, 60%, 100% { transform:translateY(0); opacity:.4; } 30% { transform:translateY(-5px); opacity:1; } } .ai-code { margin:6px 0; } .ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; } .ai-code .ai-run { margin-top:6px; } @@ -950,8 +954,12 @@ main#app > .view:not([hidden]) { animation:view-in .22s ease; } /* no fill: a la @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 { min-height:0; overflow:hidden; font-size:12px; color:var(--muted); line-height:1.45; + margin-top:4px; padding-left:10px; border-left:2px solid var(--border); } +.ai-think.open .ai-think-in { max-height:220px; overflow:auto; } +/* With thinking, the answer keeps its width: the thinking opens without + reflowing (short answers made the bubble narrow, the words then moved). */ +.ai-msg.assistant:has(> .ai-think:not([hidden])) { width:92%; } .ai-think-in p { margin:0 0 4px; } /* Folding parts (Сменить пароль, PIN-код, more settings, results) open and shut smoothly, their arrow turning, as the + menu's groups do. */ @@ -966,7 +974,7 @@ details::details-content { block-size: 0; overflow: hidden; opacity: 0; details[open]::details-content { block-size: auto; opacity: 1; } /* The assistant's chats: the name in the header, the list under it. */ .ai-head { position:relative; } -.ai-chatsel { display:inline-flex; align-items:center; gap:6px; min-width:0; max-width:65%; padding:4px 6px; margin-left:-6px; border-radius:7px; +.ai-chatsel { display:inline-flex; align-items:center; gap:6px; min-width:0; max-width:calc(100% - 64px); padding:4px 6px; margin-left:-6px; border-radius:7px; background:none; border:0; color:var(--text); font-weight:600; font-size:14px; cursor:pointer; } .ai-chatsel:hover { background:#151a21; } .ai-chatname { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -1029,3 +1037,11 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot .ai-form .ai-sub { margin:10px 0 0; font-size:15px; } .ai-form:not([data-search="searxng"]) .s-url { display:none; } .ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { display:none; } +/* A question about an action: where, the command in mono, a note. */ +.ui-extra { display:grid; gap:8px; margin-top:-4px; min-width:0; } +.ui-chip { justify-self:start; display:inline-block; vertical-align:middle; margin:-2px 2px 0; max-width:100%; padding:1px 8px; line-height:18px; border-radius:999px; background:#12202f; color:#9db8ff; + font:12px ui-monospace, SFMono-Regular, Menlo, monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.ui-code { margin:0; padding:9px 11px; background:#0c0f13; border:1px solid var(--border); border-radius:8px; max-height:220px; overflow:auto; + font:12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space:pre; color:var(--text); } +/* A command is shown as it is: no line broken inside it, a long one scrolls. */ +.ui-note { margin:0; font-size:12px; color:var(--muted); } diff --git a/internal/server/web/static/ui.js b/internal/server/web/static/ui.js index 47fb364..d25f29d 100644 --- a/internal/server/web/static/ui.js +++ b/internal/server/web/static/ui.js @@ -9,7 +9,7 @@ dlg = document.createElement('dialog'); dlg.className = 'uidlg'; dlg.innerHTML = '

' + - '
'; + '
'; document.body.appendChild(dlg); } function ask(text, o) { @@ -20,6 +20,9 @@ // is as wide as its words, up to the screen's room. const t = dlg.querySelector('.ui-text'); t.textContent = ''; + // "{chip}" in the text: the chip stands there, inside the sentence. + const parts = String(text).split('{chip}'); + text = parts.shift(); String(text).split(/(\s+)/).forEach(w => { if (w.length > 28 && !/\s/.test(w)) { const s = document.createElement('span'); @@ -27,10 +30,28 @@ t.appendChild(s); } else t.appendChild(document.createTextNode(w)); }); + // Extras of a question about an action: where (a chip), the command in + // mono, a note under it. + if (parts.length && o.chip) { + const c = document.createElement('span'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip; + t.append(c, parts.join('')); + o = Object.assign({}, o, {chip: ''}); + } + dlg.querySelectorAll('.ui-extra').forEach(e => e.remove()); + if (o.chip || o.code || o.note) { + const box = document.createElement('div'); + box.className = 'ui-extra'; + if (o.chip) { const c = document.createElement('div'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip; box.appendChild(c); } + if (o.code) { const p = document.createElement('pre'); p.className = 'ui-code'; p.textContent = o.code; box.appendChild(p); } + if (o.note) { const n = document.createElement('p'); n.className = 'ui-note'; n.textContent = o.note; box.appendChild(n); } + t.after(box); + } input.hidden = !o.input; input.value = o.value || ''; yes.textContent = o.ok || 'OK'; yes.className = 'ui-yes' + (o.danger ? ' btn danger' : ''); + // «Отмена» red, unless «да» is the red one (a delete): never two reds. + dlg.querySelector('.ui-no').classList.toggle('close', !o.danger); return new Promise(done => { const end = v => { dlg.onclose = null; done(v); }; dlg.querySelector('.ui-no').onclick = () => dlg.close('');