feat: the assistant's talk is kept in the database per user and the model sees a trimmed window of it; clear is a broom; the ball steps aside for the chat and comes back where the chat was

This commit is contained in:
redtea committed 2026-10-15 22:35:10 +03:00
1 parent 92a90646a5
commit 00617451aa
6 files changed
+283 -60

No files matched your search

+175 -35
View File
@@ -364,8 +364,45 @@ const aiAgent = `
const aiThink = ` const aiThink = `
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.` Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
// POST /api/panel/ai/chat - {messages, context}: the answer, streamed as // The talk is kept in the database, the last aiKeep messages of each user.
// plain text. A failure after the start ends the text with "[ошибка: …]". const aiKeep = 200
func (s *Server) aiAppend(owner int64, role, content string) {
if content == "" {
return
}
if len(content) > 64<<10 {
content = utf8Safe(content[:64<<10])
}
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, role, content, at) VALUES (?, ?, ?, ?)`, owner, role, content, time.Now().Unix())
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE admin_id = ? AND id <= (SELECT id FROM ai_messages WHERE admin_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, owner, owner, aiKeep)
}
// aiHistory is the user's talk, oldest first, the last limit messages.
func (s *Server) aiHistory(owner int64, limit int) []aiMsg {
rows, err := s.db.sql.Query(`SELECT role, content FROM ai_messages WHERE admin_id = ? ORDER BY id DESC LIMIT ?`, owner, limit)
out := []aiMsg{}
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var m aiMsg
if rows.Scan(&m.Role, &m.Content) == nil {
out = append(out, m)
}
}
for l, r := 0, len(out)-1; l < r; l, r = l+1, r-1 {
out[l], out[r] = out[r], out[l]
}
return out
}
// POST /api/panel/ai/chat - {message | result, context, agent, think}: the
// new words (or an action's result) are kept with the talk, and the answer,
// streamed as plain text, is kept too. The model sees a window of the talk
// (aiWindow) and the panel as it is now. 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) {
a := s.currentAdmin(r) a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" { if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
@@ -373,44 +410,21 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
return return
} }
var in struct { var in struct {
Messages []aiMsg `json:"messages"` Message string `json:"message"`
Result string `json:"result"`
Context string `json:"context"` Context string `json:"context"`
Agent bool `json:"agent"` Agent bool `json:"agent"`
Think bool `json:"think"` 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 || (in.Message == "" && in.Result == "") {
http.Error(w, "плохой запрос", http.StatusBadRequest) http.Error(w, "плохой запрос", http.StatusBadRequest)
return return
} }
// The last 30 messages are enough to go on. s.aiAppend(a.ID, "result", in.Result)
if n := len(in.Messages); n > 30 { s.aiAppend(a.ID, "user", in.Message)
in.Messages = in.Messages[n-30:] msgs := aiWindow(s.aiHistory(a.ID, 60), 24000)
} if len(in.Context) > 30000 {
for i := range in.Messages { in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
if in.Messages[i].Role != "assistant" {
in.Messages[i].Role = "user"
}
}
if len(in.Context) > 60000 {
in.Context = in.Context[len(in.Context)-60000:]
for !utf8.ValidString(in.Context) && in.Context != "" {
in.Context = in.Context[1:]
}
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
fl, _ := w.(http.Flusher)
wrote := false
emit := func(t string) {
if t == "" {
return
}
wrote = true
_, _ = io.WriteString(w, t)
if fl != nil {
fl.Flush()
}
} }
system := aiSystem system := aiSystem
if in.Agent { if in.Agent {
@@ -419,15 +433,54 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
if in.Think { if in.Think {
system += aiThink system += aiThink
} }
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit) w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
fl, _ := w.(http.Flusher)
var answer strings.Builder
emit := func(t string) {
if t == "" {
return
}
answer.WriteString(t)
_, _ = io.WriteString(w, t)
if fl != nil {
fl.Flush()
}
}
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
s.aiAppend(a.ID, "assistant", stripThink(answer.String()))
if err != nil && r.Context().Err() == nil { if err != nil && r.Context().Err() == nil {
if !wrote { if answer.Len() == 0 {
w.WriteHeader(http.StatusBadGateway) w.WriteHeader(http.StatusBadGateway)
} }
_, _ = io.WriteString(w, "\n[ошибка: "+err.Error()+"]") _, _ = io.WriteString(w, "\n[ошибка: "+err.Error()+"]")
} }
} }
// GET /api/panel/ai/history - the talk to show, oldest first.
func (s *Server) handleAIHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.aiHistory(s.currentAdmin(r).ID, 60))
}
// POST /api/panel/ai/clear - a new talk.
func (s *Server) handleAIClear(w http.ResponseWriter, r *http.Request) {
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE admin_id = ?`, s.currentAdmin(r).ID)
writeJSON(w, map[string]any{"ok": true})
}
// POST /api/panel/ai/note - {content}: an action's result the user got by
// hand, kept with the talk for the next question.
func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) {
var in struct {
Content string `json:"content"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in) == nil {
s.aiAppend(s.currentAdmin(r).ID, "result", in.Content)
}
writeJSON(w, map[string]any{"ok": true})
}
// GET /api/panel/ai - whether the assistant is on for this user. // GET /api/panel/ai - whether the assistant is on for this user.
func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID)) on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
@@ -473,3 +526,90 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
} }
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther) http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
} }
// aiWindow is what of the stored talk goes to the model: the newest
// messages within budget characters - the last one always - with older
// action results and answers cut to their head and tail (the newest result
// whole), results given as the user's words, and messages of one role in a
// row merged (some models want them alternating). It starts with the user.
func aiWindow(rows []aiMsg, budget int) []aiMsg {
cut := func(s string, max int) string {
if len(s) <= max {
return s
}
head, tail := max/3, max-max/3
return utf8Safe(s[:head]) + "\n…[сокращено]…\n" + utf8Safe(s[len(s)-tail:])
}
lastResult := -1
for i := len(rows) - 1; i >= 0; i-- {
if rows[i].Role == "result" {
lastResult = i
break
}
}
var picked []aiMsg
total := 0
for i := len(rows) - 1; i >= 0; i-- {
m := rows[i]
switch {
case m.Role == "result":
max := 1500
if i == lastResult {
max = 12000
}
m = aiMsg{Role: "user", Content: "Результат действия:\n" + cut(m.Content, max)}
case m.Role == "assistant" && i < len(rows)-2:
m.Content = cut(m.Content, 3000)
case m.Role != "assistant":
m.Role = "user"
}
if len(picked) > 0 && total+len(m.Content) > budget {
break
}
total += len(m.Content)
picked = append(picked, m)
}
// Oldest first, from the user's first word.
for l, r := 0, len(picked)-1; l < r; l, r = l+1, r-1 {
picked[l], picked[r] = picked[r], picked[l]
}
for len(picked) > 0 && picked[0].Role != "user" {
picked = picked[1:]
}
var out []aiMsg
for _, m := range picked {
if n := len(out); n > 0 && out[n-1].Role == m.Role {
out[n-1].Content += "\n\n" + m.Content
continue
}
out = append(out, m)
}
return out
}
// utf8Safe trims broken runes off the ends of a cut string.
func utf8Safe(s string) string {
for len(s) > 0 && !utf8.ValidString(s) {
if r, _ := utf8.DecodeRuneInString(s); r == utf8.RuneError {
s = s[1:]
} else {
s = s[:len(s)-1]
}
}
return s
}
// stripThink drops the thinking (::думаю:: … ::ответ::, or <think>) before
// an answer is kept: it is not worth its room in the talk.
func stripThink(s string) string {
t := strings.TrimLeft(s, " \n")
for _, p := range [][2]string{{"::думаю::", "::ответ::"}, {"<think>", "</think>"}} {
if strings.HasPrefix(t, p[0]) {
if i := strings.Index(t, p[1]); i >= 0 {
return strings.TrimLeft(t[i+len(p[1]):], " \n")
}
return ""
}
}
return s
}
+43 -1
View File
@@ -130,7 +130,7 @@ func TestAIChatNeedsUserOn(t *testing.T) {
_ = s.db.SetConfig(cfgAIType, "openai") _ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, srv.URL) _ = s.db.SetConfig(cfgAIURL, srv.URL)
post := func() *httptest.ResponseRecorder { post := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"messages":[{"role":"user","content":"hi"}],"context":"x"}`)) req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"hi","context":"x"}`))
req.AddCookie(c) req.AddCookie(c)
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req) s.Handler().ServeHTTP(w, req)
@@ -146,6 +146,10 @@ func TestAIChatNeedsUserOn(t *testing.T) {
if w.Code != 200 || string(b) != "ok" { if w.Code != 200 || string(b) != "ok" {
t.Fatalf("on: %d %q", w.Code, b) t.Fatalf("on: %d %q", w.Code, b)
} }
// The talk is kept: the question and the answer.
if h := s.aiHistory(a.ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" {
t.Fatalf("history: %+v", h)
}
} }
func TestVersionAtLeast(t *testing.T) { func TestVersionAtLeast(t *testing.T) {
@@ -158,3 +162,41 @@ func TestVersionAtLeast(t *testing.T) {
} }
} }
} }
// The window the model sees: newest first within the budget, old results
// and answers cut down, results as the user's words, same roles merged.
func TestAIWindow(t *testing.T) {
big := strings.Repeat("x", 5000)
rows := []aiMsg{
{Role: "user", Content: "старое"},
{Role: "assistant", Content: big},
{Role: "result", Content: big},
{Role: "user", Content: "дальше"},
{Role: "assistant", Content: "ок"},
{Role: "result", Content: "вывод"},
}
w := aiWindow(rows, 100000)
if len(w) != 5 || w[0].Role != "user" || w[len(w)-1].Role != "user" {
t.Fatalf("roles: %+v", w)
}
if len(w[1].Content) > 3200 || len(w[2].Content) > 1700+len("дальше")+40 {
t.Fatalf("old not cut: %d %d", len(w[1].Content), len(w[2].Content))
}
if !strings.Contains(w[4].Content, "вывод") || !strings.HasPrefix(w[4].Content, "Результат") {
t.Fatalf("last result: %q", w[4].Content)
}
// A small budget keeps the newest, starting with the user.
w = aiWindow(rows, 60)
if len(w) == 0 || w[0].Role != "user" || !strings.Contains(w[len(w)-1].Content, "вывод") {
t.Fatalf("small: %+v", w)
}
}
func TestStripThink(t *testing.T) {
if got := stripThink("::думаю::\nхм\n::ответ::\n391"); got != "391" {
t.Fatalf("%q", got)
}
if got := stripThink("просто"); got != "просто" {
t.Fatalf("%q", got)
}
}
+5
View File
@@ -123,6 +123,11 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`, `ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`, `ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
role TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS ai_messages_admin ON ai_messages(admin_id, id)`,
`CREATE TABLE IF NOT EXISTS ssh_hosts ( `CREATE TABLE IF NOT EXISTS ssh_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
+3
View File
@@ -256,6 +256,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/ssh/hosts/{hid}/delete", s.requireAdmin(s.handleSSHHostDelete)) mux.Handle("POST /api/panel/ssh/hosts/{hid}/delete", s.requireAdmin(s.handleSSHHostDelete))
mux.Handle("POST /api/panel/ai/toggle", s.requireAdmin(s.handleAIToggle)) mux.Handle("POST /api/panel/ai/toggle", s.requireAdmin(s.handleAIToggle))
mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat)) mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat))
mux.Handle("GET /api/panel/ai/history", s.requireAdmin(s.handleAIHistory))
mux.Handle("POST /api/panel/ai/clear", s.requireAdmin(s.handleAIClear))
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI)) mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
+52 -24
View File
@@ -21,7 +21,7 @@
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>' +
'<button type="button" class="btn ghost small ai-clear" title="Новый разговор">Очистить</button>' + '<button type="button" class="btn ghost small icon sq ai-clear" title="Очистить разговор" aria-label="Очистить разговор"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 4 5 5"/><path d="M17.5 6.5 11 13"/><path d="M11 13c-2-2-5-1.5-6.5 0L3 14.5 9.5 21l1.5-1.5c1.5-1.5 2-4.5 0-6.5z"/><path d="m6 17 2-2M8.5 19.5 10 18"/></svg></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">' + '<div class="ai-pills">' +
@@ -35,7 +35,8 @@
document.body.append(fab, box); document.body.append(fab, box);
const q = s => box.querySelector(s); const q = s => box.querySelector(s);
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'); const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
const talk = []; // {role, content} // The talk lives on the server (the database); here only what is shown.
let loaded = false;
let ac = null; let ac = null;
// Open and shut with a short slide. On a phone the chat takes the screen // Open and shut with a short slide. On a phone the chat takes the screen
@@ -43,8 +44,10 @@
const phone = () => matchMedia('(max-width: 640px)').matches; const phone = () => matchMedia('(max-width: 640px)').matches;
const isOpen = () => box.classList.contains('on'); const isOpen = () => box.classList.contains('on');
function show() { function show() {
loadTalk();
placeBox(); placeBox();
box.classList.add('on'); box.classList.add('on');
fab.classList.add('away');
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href); if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
where(); where();
if (!phone()) input.focus(); if (!phone()) input.focus();
@@ -52,10 +55,24 @@
function hide() { function hide() {
if (!isOpen()) return; if (!isOpen()) return;
if (history.state && history.state.aiChat) { history.back(); return; } if (history.state && history.state.aiChat) { history.back(); return; }
box.classList.remove('on'); shut();
} }
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) box.classList.remove('on'); }); // shut: the chat goes, and the ball comes back in its corner - where the
// chat was last moved to.
function shut() {
if (desk && !phone()) {
const b = box.getBoundingClientRect(), o = box.style.transformOrigin || 'bottom right';
const right = /right/.test(o) ? innerWidth - b.right : innerWidth - b.left - fab.offsetWidth;
const bottom = /bottom/.test(o) ? innerHeight - b.bottom : innerHeight - b.top - fab.offsetHeight;
const pos = placeFab(right, bottom);
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
}
box.classList.remove('on');
fab.classList.remove('away');
}
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
function setOn(on) { fab.hidden = !on; if (!on) hide(); } function setOn(on) { fab.hidden = !on; if (!on) hide(); }
const desk = !matchMedia('(pointer: coarse)').matches;
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {}); fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
document.addEventListener('lpra:ai', ev => setOn(ev.detail)); document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); }; fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
@@ -64,7 +81,6 @@
// The ball goes where it is dragged and stays there (kept in this // The ball goes where it is dragged and stays there (kept in this
// browser, as distances from the right and bottom edges); the chat opens // browser, as distances from the right and bottom edges); the chat opens
// beside it, toward the room, and moves by its title. // beside it, toward the room, and moves by its title.
const desk = !matchMedia('(pointer: coarse)').matches;
let moved = false; let moved = false;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function placeFab(right, bottom) { function placeFab(right, bottom) {
@@ -77,10 +93,9 @@
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; } if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12; const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2; const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
let x = toLeft ? f.right - w : f.left; // The chat takes the ball's place, grown from its corner.
let y = up ? f.top - gap - h : f.bottom + gap; const x = toLeft ? f.right - w : f.left;
// No room above or below: beside the ball. const y = up ? f.bottom - h : f.top;
if (y < 8 || y + h > innerHeight - 8) { y = clamp(f.top + f.height - h, 8, innerHeight - h - 8); x = toLeft ? f.left - gap - w : f.right + gap; }
box.style.right = box.style.bottom = 'auto'; box.style.right = box.style.bottom = 'auto';
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px'; box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px'; box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
@@ -126,12 +141,25 @@
}); });
} }
q('.ai-close').onclick = hide; q('.ai-close').onclick = hide;
q('.ai-clear').onclick = () => { q('.ai-clear').onclick = async () => {
if (!list.querySelector('.ai-msg') || !await UI.confirm('Очистить разговор?', {ok: 'Очистить', danger: true})) return;
if (ac) ac.abort(); if (ac) ac.abort();
talk.length = 0; fetch('/api/panel/ai/clear', {method: 'POST', credentials: 'same-origin'});
list.querySelectorAll('.ai-msg').forEach(m => m.remove()); list.querySelectorAll('.ai-msg').forEach(m => m.remove());
q('.ai-hello').hidden = false; q('.ai-hello').hidden = false;
}; };
// The talk so far, once, when the chat first opens.
async function loadTalk() {
if (loaded) return;
loaded = true;
try {
const h = await fetch('/api/panel/ai/history', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
h.forEach(m => {
if (m.role === 'result') showResult(m.content);
else { const el = add(m.role, m.content); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
});
} catch (e) { loaded = false; }
}
// ---------- what the panel shows ---------- // ---------- what the panel shows ----------
const W = () => window.Workspace; const W = () => window.Workspace;
@@ -304,12 +332,12 @@
list.scrollTop = list.scrollHeight; list.scrollTop = list.scrollHeight;
return el; return el;
} }
// A result goes into the talk as the user's message and shows folded. // An action's result shows folded; it reaches the talk on the server with
function result(text) { // the next question (or by note, when done by hand).
function showResult(text) {
const el = add('result', ''); const el = add('result', '');
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>'; el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
el.querySelector('pre').textContent = text; el.querySelector('pre').textContent = text;
talk.push({role: 'user', content: text});
} }
list.addEventListener('click', async ev => { list.addEventListener('click', async ev => {
const b = ev.target.closest('.ai-act'); const b = ev.target.closest('.ai-act');
@@ -320,11 +348,14 @@
b.textContent = '…'; b.textContent = '…';
const res = await perform(a); const res = await perform(a);
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово'; b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
result(res); showResult(res);
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({content: res})});
}); });
async function ask(text) { // ask sends the new words (or an action's result) and shows the answer.
if (text != null) { talk.push({role: 'user', content: text}); add('user', text); } async function ask(text, res) {
if (text) add('user', text);
if (res) showResult(res);
const el = add('assistant', ''); const el = add('assistant', '');
el.classList.add('wait'); el.classList.add('wait');
ac = new AbortController(); ac = new AbortController();
@@ -334,7 +365,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(), think: thinkOn()}) body: JSON.stringify({message: text || '', result: res || '', 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);
@@ -352,7 +383,6 @@
} finally { } finally {
el.classList.remove('wait'); el.classList.remove('wait');
render(el, answer || '[нет ответа]', true); render(el, answer || '[нет ответа]', true);
talk.push({role: 'assistant', content: stripThink(answer)});
} }
return {el, answer: stripThink(answer)}; return {el, answer: stripThink(answer)};
} }
@@ -379,16 +409,14 @@
const b = turn.el.querySelector('.ai-act'); const b = turn.el.querySelector('.ai-act');
if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) { if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) {
if (b) { b.disabled = true; b.textContent = 'Отклонено'; } if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
result('Пользователь отклонил это действие. Предложи другое или закончи.'); turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
turn = await ask(null);
continue; continue;
} }
if (b) { b.disabled = true; b.textContent = '…'; } if (b) { b.disabled = true; b.textContent = '…'; }
const res = await perform(a, ac.signal); const res = await perform(a, ac.signal);
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово'; if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
result(res); if (ac.signal.aborted) { showResult(res); break; }
if (ac.signal.aborted) break; turn = await ask(null, res);
turn = await ask(null);
} }
ac = null; ac = null;
send.innerHTML = PLANE; send.title = 'Отправить'; send.innerHTML = PLANE; send.title = 'Отправить';
+5
View File
@@ -888,3 +888,8 @@ body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
body.touch.term-active .ai-fab { bottom:110px; } body.touch.term-active .ai-fab { bottom:110px; }
.ai-form-in textarea { font-size:16px; } .ai-form-in textarea { font-size:16px; }
} }
.ai-clear { width:30px; height:30px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
/* The ball steps aside while the chat is open, and back when it shuts. */
.ai-fab { transition:transform .22s ease, opacity .22s ease, box-shadow .2s ease, right .25s ease, bottom .25s ease; }
.ai-fab.away { opacity:0; transform:scale(.5); pointer-events:none; }
body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }