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:
1 parent
92a90646a5
commit
00617451aa
6 files changed
+283
-60
No files matched your search
+175
-35
@@ -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"`
|
||||
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 <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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
box.innerHTML =
|
||||
'<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>' +
|
||||
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
|
||||
'<div class="ai-pills">' +
|
||||
@@ -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 = '<details><summary>Результат</summary><pre></pre></details>';
|
||||
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 = 'Отправить';
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user