diff --git a/internal/server/ai.go b/internal/server/ai.go index 165c193..0b8cde4 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -364,8 +364,45 @@ 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 "[ошибка: …]". +// The talk is kept in the database, the last aiKeep messages of each user. +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) { a := s.currentAdmin(r) 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 } var in struct { - Messages []aiMsg `json:"messages"` - Context string `json:"context"` - Agent bool `json:"agent"` - Think bool `json:"think"` + Message string `json:"message"` + Result string `json:"result"` + 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 { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "") { http.Error(w, "плохой запрос", http.StatusBadRequest) return } - // The last 30 messages are enough to go on. - if n := len(in.Messages); n > 30 { - in.Messages = in.Messages[n-30:] - } - for i := range in.Messages { - 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() - } + s.aiAppend(a.ID, "result", in.Result) + s.aiAppend(a.ID, "user", in.Message) + msgs := aiWindow(s.aiHistory(a.ID, 60), 24000) + if len(in.Context) > 30000 { + in.Context = utf8Safe(in.Context[len(in.Context)-30000:]) } system := aiSystem if in.Agent { @@ -419,15 +433,54 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { if in.Think { 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 !wrote { + if answer.Len() == 0 { w.WriteHeader(http.StatusBadGateway) } _, _ = 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. func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) { 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) } + +// 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 ) 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{{"::думаю::", "::ответ::"}, {"", ""}} { + 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 +} diff --git a/internal/server/ai_test.go b/internal/server/ai_test.go index 4efc2af..8d182ff 100644 --- a/internal/server/ai_test.go +++ b/internal/server/ai_test.go @@ -130,7 +130,7 @@ func TestAIChatNeedsUserOn(t *testing.T) { _ = s.db.SetConfig(cfgAIType, "openai") _ = s.db.SetConfig(cfgAIURL, srv.URL) 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) w := httptest.NewRecorder() s.Handler().ServeHTTP(w, req) @@ -146,6 +146,10 @@ func TestAIChatNeedsUserOn(t *testing.T) { if w.Code != 200 || string(b) != "ok" { 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) { @@ -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) + } +} diff --git a/internal/server/db.go b/internal/server/db.go index 6942b40..0a51fe3 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -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 role TEXT NOT NULL DEFAULT 'admin'`, `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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, diff --git a/internal/server/server.go b/internal/server/server.go index 58a3de2..cd960b2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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/ai/toggle", s.requireAdmin(s.handleAIToggle)) 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 /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 64c4873..8f7de5f 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -21,7 +21,7 @@ box.innerHTML = '
Ассистент' + - '' + + '' + '
' + '

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

' + '
' + @@ -35,7 +35,8 @@ document.body.append(fab, box); const q = s => box.querySelector(s); 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; // 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 isOpen = () => box.classList.contains('on'); function show() { + loadTalk(); placeBox(); box.classList.add('on'); + fab.classList.add('away'); if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href); where(); if (!phone()) input.focus(); @@ -52,10 +55,24 @@ function hide() { if (!isOpen()) 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(); } + 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(() => {}); document.addEventListener('lpra:ai', ev => setOn(ev.detail)); 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 // browser, as distances from the right and bottom edges); the chat opens // beside it, toward the room, and moves by its title. - const desk = !matchMedia('(pointer: coarse)').matches; let moved = false; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); function placeFab(right, bottom) { @@ -77,10 +93,9 @@ 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 toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2; - let x = toLeft ? f.right - w : f.left; - let y = up ? f.top - gap - h : f.bottom + gap; - // No room above or below: beside the ball. - 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; } + // The chat takes the ball's place, grown from its corner. + const x = toLeft ? f.right - w : f.left; + const y = up ? f.bottom - h : f.top; box.style.right = box.style.bottom = 'auto'; box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px'; box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px'; @@ -126,12 +141,25 @@ }); } 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(); - talk.length = 0; + fetch('/api/panel/ai/clear', {method: 'POST', credentials: 'same-origin'}); list.querySelectorAll('.ai-msg').forEach(m => m.remove()); 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 ---------- const W = () => window.Workspace; @@ -304,12 +332,12 @@ list.scrollTop = list.scrollHeight; return el; } - // A result goes into the talk as the user's message and shows folded. - function result(text) { + // An action's result shows folded; it reaches the talk on the server with + // the next question (or by note, when done by hand). + function showResult(text) { const el = add('result', ''); el.innerHTML = '
Результат
'; el.querySelector('pre').textContent = text; - talk.push({role: 'user', content: text}); } list.addEventListener('click', async ev => { const b = ev.target.closest('.ai-act'); @@ -320,11 +348,14 @@ b.textContent = '…'; const res = await perform(a); 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) { - if (text != null) { talk.push({role: 'user', content: text}); add('user', text); } + // ask sends the new words (or an action's result) and shows the answer. + async function ask(text, res) { + if (text) add('user', text); + if (res) showResult(res); const el = add('assistant', ''); el.classList.add('wait'); ac = new AbortController(); @@ -334,7 +365,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(), 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.body) throw new Error('ошибка ' + r.status); @@ -352,7 +383,6 @@ } finally { el.classList.remove('wait'); render(el, answer || '[нет ответа]', true); - talk.push({role: 'assistant', content: stripThink(answer)}); } return {el, answer: stripThink(answer)}; } @@ -379,16 +409,14 @@ const b = turn.el.querySelector('.ai-act'); if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) { if (b) { b.disabled = true; b.textContent = 'Отклонено'; } - result('Пользователь отклонил это действие. Предложи другое или закончи.'); - turn = await ask(null); + turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.'); continue; } if (b) { b.disabled = true; b.textContent = '…'; } const res = await perform(a, ac.signal); if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово'; - result(res); - if (ac.signal.aborted) break; - turn = await ask(null); + if (ac.signal.aborted) { showResult(res); break; } + turn = await ask(null, res); } ac = null; send.innerHTML = PLANE; send.title = 'Отправить'; diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 6e2c079..41d646e 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -888,3 +888,8 @@ body:not(.touch) .ai-fab { cursor:grab; touch-action:none; } body.touch.term-active .ai-fab { bottom:110px; } .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; }