feat: the assistant's Агент and Думать modes as pills; thinking shows folded above the answer
This commit is contained in:
1 parent
847bcd68c0
commit
8d7489b577
3 files changed
+51
-7
No files matched your search
@@ -361,6 +361,9 @@ const aiAgent = `
|
|||||||
Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг.
|
Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг.
|
||||||
Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.`
|
Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.`
|
||||||
|
|
||||||
|
const aiThink = `
|
||||||
|
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
|
||||||
|
|
||||||
// POST /api/panel/ai/chat - {messages, context}: the answer, streamed as
|
// POST /api/panel/ai/chat - {messages, context}: the answer, streamed as
|
||||||
// plain text. A failure after the start ends the text with "[ошибка: …]".
|
// plain text. A failure after the start ends the text with "[ошибка: …]".
|
||||||
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
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"`
|
Messages []aiMsg `json:"messages"`
|
||||||
Context string `json:"context"`
|
Context string `json:"context"`
|
||||||
Agent bool `json:"agent"`
|
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 {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || len(in.Messages) == 0 {
|
||||||
http.Error(w, "плохой запрос", http.StatusBadRequest)
|
http.Error(w, "плохой запрос", http.StatusBadRequest)
|
||||||
@@ -412,6 +416,9 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.Agent {
|
if in.Agent {
|
||||||
system += aiAgent
|
system += aiAgent
|
||||||
}
|
}
|
||||||
|
if in.Think {
|
||||||
|
system += aiThink
|
||||||
|
}
|
||||||
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit)
|
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit)
|
||||||
if err != nil && r.Context().Err() == nil {
|
if err != nil && r.Context().Err() == nil {
|
||||||
if !wrote {
|
if !wrote {
|
||||||
|
|||||||
@@ -20,10 +20,16 @@
|
|||||||
box.className = 'ai-box';
|
box.className = 'ai-box';
|
||||||
box.innerHTML =
|
box.innerHTML =
|
||||||
'<div class="ai-head"><b>Ассистент</b><span class="muted ai-where"></span><span class="spacer"></span>' +
|
'<div class="ai-head"><b>Ассистент</b><span class="muted ai-where"></span><span class="spacer"></span>' +
|
||||||
'<label class="ai-agent" title="Агент: сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла"><input type="checkbox"> Агент</label>' +
|
|
||||||
'<button type="button" class="btn ghost small ai-clear" title="Новый разговор">Очистить</button>' +
|
'<button type="button" class="btn ghost small ai-clear" title="Новый разговор">Очистить</button>' +
|
||||||
'<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
|
'<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
|
||||||
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
|
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
|
||||||
|
'<div class="ai-pills">' +
|
||||||
|
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>Агент</button>' +
|
||||||
|
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
|
||||||
|
'</div>' +
|
||||||
'<form class="ai-form-in"><textarea rows="1" placeholder="Сообщение…"></textarea>' +
|
'<form class="ai-form-in"><textarea rows="1" placeholder="Сообщение…"></textarea>' +
|
||||||
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></form>';
|
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></form>';
|
||||||
document.body.append(fab, box);
|
document.body.append(fab, box);
|
||||||
@@ -193,11 +199,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- messages ----------
|
// ---------- messages ----------
|
||||||
|
// stripThink: the answer without its thinking - what is acted on and kept.
|
||||||
|
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
|
||||||
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'};
|
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'};
|
||||||
// render: text with ``` blocks; action blocks get their button.
|
// render: text with ``` blocks; action blocks get their button.
|
||||||
function render(el, text, done) {
|
function render(el, text, done) {
|
||||||
const parts = text.split(/```/);
|
// Thinking first, folded: ::думаю:: … ::ответ:: (or <think>), open while
|
||||||
|
// it is going on.
|
||||||
let html = '', n = 0;
|
let html = '', n = 0;
|
||||||
|
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>';
|
||||||
|
text = text.slice(th[0].length);
|
||||||
|
}
|
||||||
|
const parts = text.split(/```/);
|
||||||
parts.forEach((part, i) => {
|
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) { html += '<div class="ai-text">' + esc(part).replace(/\n/g, '<br>') + '</div>'; return; }
|
||||||
const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part);
|
const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part);
|
||||||
@@ -250,7 +267,7 @@
|
|||||||
await loadMachines();
|
await loadMachines();
|
||||||
const r = await fetch('/api/panel/ai/chat', {
|
const r = await fetch('/api/panel/ai/chat', {
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
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.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
||||||
if (!r.body) throw new Error('ошибка ' + r.status);
|
if (!r.body) throw new Error('ошибка ' + r.status);
|
||||||
@@ -268,11 +285,22 @@
|
|||||||
} finally {
|
} finally {
|
||||||
el.classList.remove('wait');
|
el.classList.remove('wait');
|
||||||
render(el, answer || '[нет ответа]', true);
|
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
|
// talkTurn: one question, and in agent mode the steps after it - the
|
||||||
// first action of each answer done (asking first for commands and writes)
|
// first action of each answer done (asking first for commands and writes)
|
||||||
// and its result sent back - until an answer asks for nothing.
|
// and its result sent back - until an answer asks for nothing.
|
||||||
|
|||||||
@@ -842,7 +842,7 @@ body.touch.term-active .ai-fab { bottom:104px; }
|
|||||||
.ai-code { margin:6px 0; }
|
.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 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-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);
|
.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; }
|
background:#0c0f13; border:1px solid var(--border); border-radius:8px; }
|
||||||
.ai-form-in textarea:focus { outline:none; border-color:var(--accent); }
|
.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 summary { cursor:pointer; }
|
||||||
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
|
.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; }
|
.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; }
|
||||||
Reference in new issue
Block a user