From 8d7489b57738ab08a10928e92b194942fe857b9e Mon Sep 17 00:00:00 2001 From: iRedTea Date: Thu, 15 Oct 2026 22:24:29 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20the=20assistant's=20=D0=90=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=20and=20=D0=94=D1=83=D0=BC=D0=B0=D1=82=D1=8C=20mod?= =?UTF-8?q?es=20as=20pills;=20thinking=20shows=20folded=20above=20the=20an?= =?UTF-8?q?swer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/ai.go | 7 ++++++ internal/server/web/static/ai.js | 40 +++++++++++++++++++++++++----- internal/server/web/static/app.css | 11 +++++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/internal/server/ai.go b/internal/server/ai.go index b383e9c..165c193 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -361,6 +361,9 @@ const aiAgent = ` Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг. Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.` +const aiThink = ` +Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.` + // POST /api/panel/ai/chat - {messages, context}: the answer, streamed as // plain text. A failure after the start ends the text with "[ошибка: …]". func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { @@ -373,6 +376,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { Messages []aiMsg `json:"messages"` Context string `json:"context"` Agent bool `json:"agent"` + Think bool `json:"think"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || len(in.Messages) == 0 { http.Error(w, "плохой запрос", http.StatusBadRequest) @@ -412,6 +416,9 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { if in.Agent { system += aiAgent } + if in.Think { + system += aiThink + } err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit) if err != nil && r.Context().Err() == nil { if !wrote { diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 0714131..aa0760d 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -20,10 +20,16 @@ box.className = 'ai-box'; box.innerHTML = '
Ассистент' + - '' + + '' + '
' + '

Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.

' + + '
' + + '' + + '' + + '
' + '
' + '
'; document.body.append(fab, box); @@ -193,11 +199,22 @@ } // ---------- messages ---------- + // stripThink: the answer without its thinking - what is acted on and kept. + 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. function render(el, text, done) { - const parts = text.split(/```/); + // Thinking first, folded: ::думаю:: … ::ответ:: (or ), open while + // it is going on. let html = '', n = 0; + const th = /^\s*(?:|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text); + if (th) { + const going = !th[2]; + html += '
' + (going ? 'Думает…' : 'Размышления') + '
' + + esc(th[1].trim()).replace(/\n/g, '
') + '
'; + text = text.slice(th[0].length); + } + const parts = text.split(/```/); parts.forEach((part, i) => { if (i % 2 === 0) { html += '
' + esc(part).replace(/\n/g, '
') + '
'; return; } const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part); @@ -250,7 +267,7 @@ await loadMachines(); const r = await fetch('/api/panel/ai/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal, - body: JSON.stringify({messages: talk, context: context(), agent: agentOn()}) + body: JSON.stringify({messages: talk, context: context(), agent: agentOn(), think: thinkOn()}) }); if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; } if (!r.body) throw new Error('ошибка ' + r.status); @@ -268,11 +285,22 @@ } finally { el.classList.remove('wait'); render(el, answer || '[нет ответа]', true); - talk.push({role: 'assistant', content: answer}); + talk.push({role: 'assistant', content: stripThink(answer)}); } - return {el, answer}; + return {el, answer: stripThink(answer)}; } - const agentOn = () => q('.ai-agent input').checked; + const pill = k => q('.ai-pill[data-mode="' + k + '"]'); + const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true'; + const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true'; + // The modes are kept in this browser. + box.querySelectorAll('.ai-pill').forEach(b => { + try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ } + b.onclick = () => { + const on = b.getAttribute('aria-pressed') !== 'true'; + b.setAttribute('aria-pressed', String(on)); + try { localStorage.setItem('lpra.ai.' + b.dataset.mode, on ? '1' : ''); } catch (e) { /* not kept */ } + }; + }); // talkTurn: one question, and in agent mode the steps after it - the // first action of each answer done (asking first for commands and writes) // and its result sent back - until an answer asks for nothing. diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 3af4e7c..516dad4 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -842,7 +842,7 @@ body.touch.term-active .ai-fab { bottom:104px; } .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; } -.ai-form-in { display:flex; align-items:flex-end; gap:8px; padding:10px; border-top:1px solid var(--border); } +.ai-form-in { display:flex; align-items:flex-end; gap:8px; padding:8px 10px 10px; } .ai-form-in textarea { flex:1; resize:none; font:inherit; font-size:14px; line-height:20px; padding:9px 10px; height:40px; max-height:140px; overflow-y:auto; color:var(--text); background:#0c0f13; border:1px solid var(--border); border-radius:8px; } .ai-form-in textarea:focus { outline:none; border-color:var(--accent); } @@ -867,3 +867,12 @@ dialog.uidlg { max-width:420px; width:92vw; } .ai-msg.result summary { cursor:pointer; } .ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; } .ai-cap { font-size:12px; margin-bottom:4px; } +/* Modes as pills: on - lit in the accent, off - a quiet outline. */ +.ai-pills { display:flex; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); } +.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 12px; border-radius:999px; font-size:12.5px; font-weight:500; + 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; }