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; }