diff --git a/internal/server/ai.go b/internal/server/ai.go index ae8fd74..d9c0755 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -364,23 +364,104 @@ const aiAgent = ` const aiThink = ` Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.` -// The talk is kept in the database, the last aiKeep messages of each user. -const aiKeep = 200 +// The talks are kept in the database: each user's chats, newest first, the +// last aiKeep messages of each and at most aiChatsKeep chats. +const ( + aiKeep = 200 + aiChatsKeep = 50 +) -func (s *Server) aiAppend(owner int64, role, content string) { - if content == "" { +type aiChat struct { + ID int64 `json:"id"` + Title string `json:"title"` + Updated int64 `json:"updated"` +} + +// aiNewChat starts a talk; the oldest go past aiChatsKeep. +func (s *Server) aiNewChat(owner int64) int64 { + now := time.Now().Unix() + id, err := s.db.sql.insert(`INSERT INTO ai_chats (admin_id, title, created, updated) VALUES (?, '', ?, ?)`, owner, now, now) + if err != nil { + return 0 + } + rows, err := s.db.sql.Query(`SELECT id FROM ai_chats WHERE admin_id = ? ORDER BY updated DESC, id DESC LIMIT -1 OFFSET ?`, owner, aiChatsKeep) + if s.db.sql.pg { + rows, err = s.db.sql.Query(`SELECT id FROM ai_chats WHERE admin_id = ? ORDER BY updated DESC, id DESC OFFSET ?`, owner, aiChatsKeep) + } + if err == nil { + var old []int64 + for rows.Next() { + var c int64 + if rows.Scan(&c) == nil { + old = append(old, c) + } + } + rows.Close() + for _, c := range old { + s.aiDeleteChat(owner, c) + } + } + return id +} + +// aiOwns: chat is one of owner's. +func (s *Server) aiOwns(owner, chat int64) bool { + var n int + _ = s.db.sql.QueryRow(`SELECT COUNT(*) FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner).Scan(&n) + return n == 1 +} + +func (s *Server) aiChats(owner int64) []aiChat { + out := []aiChat{} + rows, err := s.db.sql.Query(`SELECT id, title, updated FROM ai_chats WHERE admin_id = ? ORDER BY updated DESC, id DESC`, owner) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var c aiChat + if rows.Scan(&c.ID, &c.Title, &c.Updated) == nil { + if c.Title == "" { + c.Title = "Новый чат" + } + out = append(out, c) + } + } + return out +} + +func (s *Server) aiDeleteChat(owner, chat int64) { + if !s.aiOwns(owner, chat) { + return + } + _, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND admin_id = ?`, chat, owner) + _, _ = s.db.sql.Exec(`DELETE FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner) +} + +// aiAppendTo keeps a message of owner's chat; the first question names it. +func (s *Server) aiAppendTo(owner, chat int64, role, content string) { + if content == "" || !s.aiOwns(owner, chat) { 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) + now := time.Now().Unix() + _, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at) VALUES (?, ?, ?, ?, ?)`, owner, chat, role, content, now) + _, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep) + _, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat) + if role == "user" { + t := strings.Join(strings.Fields(content), " ") + if r := []rune(t); len(r) > 40 { + t = string(r[:40]) + "…" + } + _, _ = s.db.sql.Exec(`UPDATE ai_chats SET title = ? WHERE id = ? AND title = ''`, t, chat) + } } -// 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) +// aiHistoryOf is a chat's talk, oldest first, the last limit messages. +func (s *Server) aiHistoryOf(owner, chat int64, limit int) []aiMsg { + rows, err := s.db.sql.Query(`SELECT role, content FROM ai_messages WHERE admin_id = ? AND chat_id = ? ORDER BY id DESC LIMIT ?`, owner, chat, limit) out := []aiMsg{} if err != nil { return out @@ -398,6 +479,27 @@ func (s *Server) aiHistory(owner int64, limit int) []aiMsg { return out } +// aiAdopt gives the talk kept before there were several (chat 0) a chat of +// its own, once. +func (s *Server) aiAdopt(owner int64) { + var n int + _ = s.db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE admin_id = ? AND chat_id = 0`, owner).Scan(&n) + if n == 0 { + return + } + id := s.aiNewChat(owner) + _, _ = s.db.sql.Exec(`UPDATE ai_messages SET chat_id = ? WHERE admin_id = ? AND chat_id = 0`, id, owner) + var first string + _ = s.db.sql.QueryRow(`SELECT content FROM ai_messages WHERE chat_id = ? AND role = 'user' ORDER BY id LIMIT 1`, id).Scan(&first) + if r := []rune(strings.Join(strings.Fields(first), " ")); len(r) > 0 { + t := string(r) + if len(r) > 40 { + t = string(r[:40]) + "…" + } + _, _ = s.db.sql.Exec(`UPDATE ai_chats SET title = ? WHERE id = ?`, t, id) + } +} + // 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 @@ -415,14 +517,21 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { Context string `json:"context"` Agent bool `json:"agent"` Think bool `json:"think"` + Chat int64 `json:"chat"` } 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 } - s.aiAppend(a.ID, "result", in.Result) - s.aiAppend(a.ID, "user", in.Message) - msgs := aiWindow(s.aiHistory(a.ID, 60), 24000) + // No chat (or not one of the user's): a new one; its id goes back in + // X-Chat-Id. + if in.Chat == 0 || !s.aiOwns(a.ID, in.Chat) { + in.Chat = s.aiNewChat(a.ID) + } + w.Header().Set("X-Chat-Id", strconv.FormatInt(in.Chat, 10)) + s.aiAppendTo(a.ID, in.Chat, "result", in.Result) + s.aiAppendTo(a.ID, in.Chat, "user", in.Message) + msgs := aiWindow(s.aiHistoryOf(a.ID, in.Chat, 60), 24000) if len(in.Context) > 30000 { in.Context = utf8Safe(in.Context[len(in.Context)-30000:]) } @@ -449,7 +558,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { } } err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit) - s.aiAppend(a.ID, "assistant", stripThink(answer.String())) + s.aiAppendTo(a.ID, in.Chat, "assistant", stripThink(answer.String())) if err != nil && r.Context().Err() == nil { if answer.Len() == 0 { w.WriteHeader(http.StatusBadGateway) @@ -458,25 +567,40 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { } } -// 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)) +// GET /api/panel/ai/chats - the user's chats, newest first. +func (s *Server) handleAIChats(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + s.aiAdopt(a.ID) + writeJSON(w, s.aiChats(a.ID)) } -// 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) +// POST /api/panel/ai/chats - a new chat. +func (s *Server) handleAIChatNew(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"ok": true, "id": s.aiNewChat(s.currentAdmin(r).ID)}) +} + +// POST /api/panel/ai/chats/{cid}/delete +func (s *Server) handleAIChatDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("cid"), 10, 64) + s.aiDeleteChat(s.currentAdmin(r).ID, 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. +// GET /api/panel/ai/chats/{cid} - a chat's talk to show, oldest first. +func (s *Server) handleAIHistory(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("cid"), 10, 64) + writeJSON(w, s.aiHistoryOf(s.currentAdmin(r).ID, id, 60)) +} + +// POST /api/panel/ai/note - {chat, content}: an action's result the user got +// by hand, kept with the chat for the next question. func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) { var in struct { + Chat int64 `json:"chat"` 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) + s.aiAppendTo(s.currentAdmin(r).ID, in.Chat, "result", in.Content) } writeJSON(w, map[string]any{"ok": true}) } diff --git a/internal/server/ai_test.go b/internal/server/ai_test.go index 8d182ff..f140ed0 100644 --- a/internal/server/ai_test.go +++ b/internal/server/ai_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" ) @@ -147,7 +148,11 @@ func TestAIChatNeedsUserOn(t *testing.T) { 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" { + cs := s.aiChats(a.ID) + if len(cs) != 1 || cs[0].Title != "hi" || w.Header().Get("X-Chat-Id") != strconv.FormatInt(cs[0].ID, 10) { + t.Fatalf("chats: %+v %q", cs, w.Header().Get("X-Chat-Id")) + } + if h := s.aiHistoryOf(a.ID, cs[0].ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" { t.Fatalf("history: %+v", h) } } @@ -200,3 +205,33 @@ func TestStripThink(t *testing.T) { t.Fatalf("%q", got) } } + +// Several talks: each its own messages; a new one is named by its first +// question; deleting one leaves the others. +func TestAIChats(t *testing.T) { + s, _ := newTestServer(t) + a, _ := s.db.AdminByUsername("adm") + one := s.aiNewChat(a.ID) + two := s.aiNewChat(a.ID) + s.aiAppendTo(a.ID, one, "user", "первый вопрос про nginx и его настройки, длинный-длинный") + s.aiAppendTo(a.ID, two, "user", "второй") + if h := s.aiHistoryOf(a.ID, one, 10); len(h) != 1 || h[0].Content[:5] != "перв"[:5] { + t.Fatalf("one: %+v", h) + } + list := s.aiChats(a.ID) + if len(list) != 2 || list[0].ID != two || !strings.HasPrefix(list[1].Title, "первый вопрос") || len([]rune(list[1].Title)) > 41 { + t.Fatalf("list: %+v", list) + } + s.aiDeleteChat(a.ID, one) + if list = s.aiChats(a.ID); len(list) != 1 || len(s.aiHistoryOf(a.ID, one, 10)) != 0 { + t.Fatalf("after delete: %+v", list) + } + // Another user sees none of them and cannot write into them. + if len(s.aiChats(a.ID+1)) != 0 { + t.Fatal("another user sees the talks") + } + s.aiAppendTo(a.ID+1, two, "user", "чужое") + if len(s.aiHistoryOf(a.ID, two, 10)) != 1 { + t.Fatal("another user wrote into the talk") + } +} diff --git a/internal/server/db.go b/internal/server/db.go index bcc7d7a..f73e8cb 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -128,6 +128,12 @@ CREATE TABLE IF NOT EXISTS forward_rules ( 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)`, + `ALTER TABLE ai_messages ADD COLUMN chat_id INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS ai_chats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + title TEXT NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS ai_messages_chat ON ai_messages(chat_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/dbpg.go b/internal/server/dbpg.go index f08e16c..f6f83c9 100644 --- a/internal/server/dbpg.go +++ b/internal/server/dbpg.go @@ -126,7 +126,7 @@ func OpenPostgres(c PGConf, sqlitePath string) (*DB, error) { // copyTables are copied parents first, for the foreign keys. var copyTables = []string{"config", "admins", "agents", "admin_sessions", "audit", "ufw_rules", "admin_keys", - "forward_rules", "admin_agents", "ssh_hosts", "ai_messages"} + "forward_rules", "admin_agents", "ssh_hosts", "ai_chats", "ai_messages"} // copyDB copies every row of src into dst (an empty PostgreSQL), in one // transaction, then moves the id counters past the copied ids. diff --git a/internal/server/dbpg_test.go b/internal/server/dbpg_test.go index 78917b8..b72cb43 100644 --- a/internal/server/dbpg_test.go +++ b/internal/server/dbpg_test.go @@ -1,6 +1,7 @@ package server import ( + "database/sql" "encoding/json" "os" "testing" @@ -29,9 +30,11 @@ func TestPostgres(t *testing.T) { if _, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "pw"}); err != nil { t.Fatal(err) } - s.aiAppend(a.ID, "user", "привет") + chat := s.aiNewChat(a.ID) + s.aiAppendTo(a.ID, chat, "user", "привет") _ = s.db.SetUserAgents(a.ID, []string{"x1"}) + pgReset(t, c) pg, err := OpenPostgres(c, s.db.path) if err != nil { t.Fatal(err) @@ -58,7 +61,7 @@ func TestPostgres(t *testing.T) { if err := p.fillSaved(a.ID, tg); err != nil || tg.Password != "pw" { t.Fatalf("saved host: %+v %v", tg, err) } - if h := p.aiHistory(a.ID, 10); len(h) != 1 || h[0].Content != "привет" { + if h := p.aiHistoryOf(a.ID, chat, 10); len(h) != 1 || h[0].Content != "привет" { t.Fatalf("talk: %+v", h) } // New rows go on after the copied ids. @@ -78,8 +81,50 @@ func TestPostgres(t *testing.T) { if err := pg.SetUserAgents(id, []string{"x1", "x1"}); err != nil { t.Fatalf("grants: %v", err) } - p.aiAppend(a.ID, "assistant", "ответ") - if h := p.aiHistory(a.ID, 10); len(h) != 2 { + p.aiAppendTo(a.ID, chat, "assistant", "ответ") + if h := p.aiHistoryOf(a.ID, chat, 10); len(h) != 2 { t.Fatalf("talk after: %+v", h) } } + +// Chats on PostgreSQL: past the limit the oldest go. +func TestPostgresChats(t *testing.T) { + raw := os.Getenv("LPRA_TEST_PG") + if raw == "" { + t.Skip("LPRA_TEST_PG not set") + } + var c PGConf + _ = json.Unmarshal([]byte(raw), &c) + s, _ := newTestServer(t) + pgReset(t, c) + pg, err := OpenPostgres(c, s.db.path) + if err != nil { + t.Fatal(err) + } + defer pg.Close() + defer func() { + for i := len(copyTables) - 1; i >= 0; i-- { + _, _ = pg.sql.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`) + } + }() + p := &Server{db: pg} + a, _ := pg.AdminByUsername("adm") + for i := 0; i < aiChatsKeep+3; i++ { + p.aiAppendTo(a.ID, p.aiNewChat(a.ID), "user", "q") + } + if n := len(p.aiChats(a.ID)); n != aiChatsKeep { + t.Fatalf("chats kept: %d", n) + } +} + +// pgReset empties the test database: the copy runs only into an empty one. +func pgReset(t *testing.T, c PGConf) { + d, err := sql.Open("pgx", c.DSN()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + for i := len(copyTables) - 1; i >= 0; i-- { + _, _ = d.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5bfe2e8..3f68297 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -257,8 +257,10 @@ 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("GET /api/panel/ai/chats", s.requireAdmin(s.handleAIChats)) + mux.Handle("POST /api/panel/ai/chats", s.requireAdmin(s.handleAIChatNew)) + mux.Handle("GET /api/panel/ai/chats/{cid}", s.requireAdmin(s.handleAIHistory)) + mux.Handle("POST /api/panel/ai/chats/{cid}/delete", s.requireAdmin(s.handleAIChatDelete)) mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote)) mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI)) mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB)) diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 62d303c..a8f960a 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -19,10 +19,11 @@ const box = document.createElement('div'); box.className = 'ai-box'; box.innerHTML = - '
Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.