feat: the assistant keeps several chats - the current one named in the header, the list under its arrow to open or delete; actions as compact cards; folding parts open smoothly

This commit is contained in:
redtea committed 2026-10-16 10:06:06 +03:00
1 parent 6d8f39f2a5
commit b23943ff42
8 files changed
+384 -50

No files matched your search

+146 -22
View File
@@ -364,23 +364,104 @@ const aiAgent = `
const aiThink = ` const aiThink = `
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.` Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
// The talk is kept in the database, the last aiKeep messages of each user. // The talks are kept in the database: each user's chats, newest first, the
const aiKeep = 200 // last aiKeep messages of each and at most aiChatsKeep chats.
const (
aiKeep = 200
aiChatsKeep = 50
)
func (s *Server) aiAppend(owner int64, role, content string) { type aiChat struct {
if content == "" { 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 return
} }
if len(content) > 64<<10 { if len(content) > 64<<10 {
content = utf8Safe(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()) now := 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) _, _ = 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. // aiHistoryOf is a chat's talk, oldest first, the last limit messages.
func (s *Server) aiHistory(owner int64, limit int) []aiMsg { 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 = ? ORDER BY id DESC LIMIT ?`, owner, limit) 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{} out := []aiMsg{}
if err != nil { if err != nil {
return out return out
@@ -398,6 +479,27 @@ func (s *Server) aiHistory(owner int64, limit int) []aiMsg {
return out 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 // 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, // 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 // 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"` Context string `json:"context"`
Agent bool `json:"agent"` Agent bool `json:"agent"`
Think bool `json:"think"` 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 == "") { 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
} }
s.aiAppend(a.ID, "result", in.Result) // No chat (or not one of the user's): a new one; its id goes back in
s.aiAppend(a.ID, "user", in.Message) // X-Chat-Id.
msgs := aiWindow(s.aiHistory(a.ID, 60), 24000) 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 { if len(in.Context) > 30000 {
in.Context = utf8Safe(in.Context[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) 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 err != nil && r.Context().Err() == nil {
if answer.Len() == 0 { if answer.Len() == 0 {
w.WriteHeader(http.StatusBadGateway) 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. // GET /api/panel/ai/chats - the user's chats, newest first.
func (s *Server) handleAIHistory(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAIChats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.aiHistory(s.currentAdmin(r).ID, 60)) a := s.currentAdmin(r)
s.aiAdopt(a.ID)
writeJSON(w, s.aiChats(a.ID))
} }
// POST /api/panel/ai/clear - a new talk. // POST /api/panel/ai/chats - a new chat.
func (s *Server) handleAIClear(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAIChatNew(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, "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}) writeJSON(w, map[string]any{"ok": true})
} }
// POST /api/panel/ai/note - {content}: an action's result the user got by // GET /api/panel/ai/chats/{cid} - a chat's talk to show, oldest first.
// hand, kept with the talk for the next question. 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) { func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) {
var in struct { var in struct {
Chat int64 `json:"chat"`
Content string `json:"content"` Content string `json:"content"`
} }
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in) == nil { 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}) writeJSON(w, map[string]any{"ok": true})
} }
+36 -1
View File
@@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -147,7 +148,11 @@ func TestAIChatNeedsUserOn(t *testing.T) {
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. // 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) t.Fatalf("history: %+v", h)
} }
} }
@@ -200,3 +205,33 @@ func TestStripThink(t *testing.T) {
t.Fatalf("%q", got) 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")
}
}
+6
View File
@@ -128,6 +128,12 @@ CREATE TABLE IF NOT EXISTS forward_rules (
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
role TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL)`, 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 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 ( `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,
+1 -1
View File
@@ -126,7 +126,7 @@ func OpenPostgres(c PGConf, sqlitePath string) (*DB, error) {
// copyTables are copied parents first, for the foreign keys. // copyTables are copied parents first, for the foreign keys.
var copyTables = []string{"config", "admins", "agents", "admin_sessions", "audit", "ufw_rules", "admin_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 // copyDB copies every row of src into dst (an empty PostgreSQL), in one
// transaction, then moves the id counters past the copied ids. // transaction, then moves the id counters past the copied ids.
+49 -4
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"os" "os"
"testing" "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 { 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) 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"}) _ = s.db.SetUserAgents(a.ID, []string{"x1"})
pgReset(t, c)
pg, err := OpenPostgres(c, s.db.path) pg, err := OpenPostgres(c, s.db.path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -58,7 +61,7 @@ func TestPostgres(t *testing.T) {
if err := p.fillSaved(a.ID, tg); err != nil || tg.Password != "pw" { if err := p.fillSaved(a.ID, tg); err != nil || tg.Password != "pw" {
t.Fatalf("saved host: %+v %v", tg, err) 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) t.Fatalf("talk: %+v", h)
} }
// New rows go on after the copied ids. // 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 { if err := pg.SetUserAgents(id, []string{"x1", "x1"}); err != nil {
t.Fatalf("grants: %v", err) t.Fatalf("grants: %v", err)
} }
p.aiAppend(a.ID, "assistant", "ответ") p.aiAppendTo(a.ID, chat, "assistant", "ответ")
if h := p.aiHistory(a.ID, 10); len(h) != 2 { if h := p.aiHistoryOf(a.ID, chat, 10); len(h) != 2 {
t.Fatalf("talk after: %+v", h) 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`)
}
}
+4 -2
View File
@@ -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/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("GET /api/panel/ai/chats", s.requireAdmin(s.handleAIChats))
mux.Handle("POST /api/panel/ai/clear", s.requireAdmin(s.handleAIClear)) 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 /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 /settings/db", s.adminsOnly(s.handleSetDB)) mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
+92 -20
View File
@@ -19,10 +19,11 @@
const box = document.createElement('div'); const box = document.createElement('div');
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"><button type="button" class="ai-chatsel" aria-expanded="false" title="Чаты"><span class="ai-chatname">Новый чат</span>' +
'<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></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>' + '<span class="muted ai-where"></span><span class="spacer"></span>' +
'<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-chats" inert><div class="ai-chats-in"><button type="button" class="ai-chatnew">+ Новый чат</button><div class="ai-chatlist"></div></div></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">' +
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' + '<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
@@ -141,24 +142,82 @@
}); });
} }
q('.ai-close').onclick = hide; q('.ai-close').onclick = hide;
q('.ai-clear').onclick = async () => { // ---------- chats ----------
if (!list.querySelector('.ai-msg') || !await UI.confirm('Очистить разговор?', {ok: 'Очистить', danger: true})) return; // Several talks, kept on the server. The header names the current one;
// its arrow opens the list: a click opens a chat, the bin deletes it.
let chatId = 0;
try { chatId = Number(localStorage.getItem('lpra.ai.chat')) || 0; } catch (e) { /* none */ }
const TRASH = '<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="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>';
const chatsEl = q('.ai-chats'), chatSel = q('.ai-chatsel');
let chats = [];
function setChat(id) {
chatId = id || 0;
try { localStorage.setItem('lpra.ai.chat', String(chatId)); } catch (e) { /* not kept */ }
const c = chats.find(x => x.id === chatId);
q('.ai-chatname').textContent = c ? c.title : 'Новый чат';
q('.ai-chatname').title = c ? c.title : '';
}
async function loadChats() {
try { chats = await fetch('/api/panel/ai/chats', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { chats = []; }
if (chatId && !chats.some(c => c.id === chatId)) chatId = 0;
setChat(chatId);
drawChats();
}
function drawChats() {
const box = q('.ai-chatlist');
box.innerHTML = '';
if (!chats.length) box.innerHTML = '<div class="muted ai-chatnone">Пока нет чатов</div>';
chats.forEach(c => {
const row = document.createElement('div');
row.className = 'ai-chatrow' + (c.id === chatId ? ' on' : '');
row.innerHTML = '<button type="button" class="ai-chatopen"></button><button type="button" class="ai-chatdel" title="Удалить чат" aria-label="Удалить чат">' + TRASH + '</button>';
row.querySelector('.ai-chatopen').textContent = c.title;
row.querySelector('.ai-chatopen').title = c.title;
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
row.querySelector('.ai-chatdel').onclick = async ev => {
ev.stopPropagation();
if (!await UI.confirm('Удалить чат «' + c.title + '»?', {ok: 'Удалить', danger: true})) return;
await fetch('/api/panel/ai/chats/' + c.id + '/delete', {method: 'POST', credentials: 'same-origin'});
if (c.id === chatId) openChat(0);
loadChats();
};
box.appendChild(row);
});
}
function toggleChats(on) {
on = on == null ? chatSel.getAttribute('aria-expanded') !== 'true' : on;
chatSel.setAttribute('aria-expanded', String(on));
chatsEl.classList.toggle('open', on);
chatsEl.inert = !on;
if (on) loadChats();
}
chatSel.onclick = () => toggleChats();
q('.ai-chatnew').onclick = () => { openChat(0); toggleChats(false); input.focus(); };
document.addEventListener('pointerdown', ev => { if (chatsEl.classList.contains('open') && !chatsEl.contains(ev.target) && !chatSel.contains(ev.target)) toggleChats(false); }, true);
// openChat shows a chat's talk (0: a new, empty one).
async function openChat(id) {
if (ac) ac.abort(); if (ac) ac.abort();
fetch('/api/panel/ai/clear', {method: 'POST', credentials: 'same-origin'}); setChat(id);
drawChats();
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 = !!id;
}; if (!id) return;
// The talk so far, once, when the chat first opens.
async function loadTalk() {
if (loaded) return;
loaded = true;
try { try {
const h = await fetch('/api/panel/ai/history', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); const h = await fetch('/api/panel/ai/chats/' + id, {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
if (chatId !== id) return;
h.forEach(m => { h.forEach(m => {
if (m.role === 'result') showResult(m.content); if (m.role === 'result') showResult(m.content);
else { const el = add(m.role, m.content); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); } else { const el = add(m.role, m.content); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
}); });
} catch (e) { loaded = false; } if (!h.length) q('.ai-hello').hidden = false;
} catch (e) { /* stays empty */ }
}
// The chat last open, when the window first opens.
async function loadTalk() {
if (loaded) return;
loaded = true;
await loadChats();
openChat(chatId);
} }
// ---------- what the panel shows ---------- // ---------- what the panel shows ----------
@@ -296,7 +355,11 @@
// ---------- messages ---------- // ---------- messages ----------
// stripThink: the answer without its thinking - what is acted on and kept. // stripThink: the answer without its thinking - what is acted on and kept.
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, ''); const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'}; const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись'};
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать'};
// render: text with ``` blocks; action blocks get their button. // render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with // render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The // Markdown; ``` blocks apart, action blocks with their button. The
@@ -331,9 +394,15 @@
const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, ''); const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, '');
const closed = i < parts.length - 1 || done; const closed = i < parts.length - 1 || done;
const act = LABEL[kind] && closed; const act = LABEL[kind] && closed;
html += '<div class="ai-code">' + (act && head !== kind ? '<div class="muted ai-cap">' + esc(head) + '</div>' : '') + // An action: a card with what and where on its title line, a small
(code || !act ? '<pre>' + esc(code) + '</pre>' : '') + // button on the right of it, the command or text below.
(act ? '<button type="button" class="btn small ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button>' : '') + '</div>'; if (act) {
const where = head.slice(kind.length).trim();
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
}); });
body.innerHTML = html; body.innerHTML = html;
el.dataset.text = text; el.dataset.text = text;
@@ -364,7 +433,7 @@
const res = await perform(a); const res = await perform(a);
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово'; b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
showResult(res); showResult(res);
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({content: res})}); fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
}); });
function responseError(status, contentType, body) { function responseError(status, contentType, body) {
@@ -388,8 +457,11 @@
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({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn()}) body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), chat: chatId})
}); });
// A new chat gets its id (and its name) from the answer.
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
if (cid && cid !== chatId) { setChat(cid); loadChats(); }
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; }
const contentType = r.headers.get('content-type') || ''; const contentType = r.headers.get('content-type') || '';
if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text())); if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text()));
+50
View File
@@ -948,3 +948,53 @@ main#app > .view:not([hidden]) { animation:view-in .22s ease both; }
.ai-think-in { min-height:0; overflow:hidden; font-size:12px; color:var(--muted); line-height:1.45; } .ai-think-in { min-height:0; overflow:hidden; font-size:12px; color:var(--muted); line-height:1.45; }
.ai-think.open .ai-think-in { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:220px; overflow:auto; } .ai-think.open .ai-think-in { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:220px; overflow:auto; }
.ai-think-in p { margin:0 0 4px; } .ai-think-in p { margin:0 0 4px; }
/* Folding parts (Сменить пароль, PIN-код, more settings, results) open and
shut smoothly, their arrow turning, as the + menu's groups do. */
:root { interpolate-size: allow-keywords; }
details > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 6px; }
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: ''; flex: none; width: 6px; height: 6px; margin: 0 4px 0 2px; border: solid currentColor;
border-width: 0 1.6px 1.6px 0; transform: rotate(-45deg); opacity: .7; transition: transform .2s ease; }
details[open] > summary::before { transform: rotate(45deg); }
details::details-content { block-size: 0; overflow: hidden; opacity: 0;
transition: block-size .25s ease, opacity .2s ease, content-visibility .25s allow-discrete; }
details[open]::details-content { block-size: auto; opacity: 1; }
/* The assistant's chats: the name in the header, the list under it. */
.ai-head { position:relative; }
.ai-chatsel { display:inline-flex; align-items:center; gap:6px; min-width:0; max-width:65%; padding:4px 6px; margin-left:-6px; border-radius:7px;
background:none; border:0; color:var(--text); font-weight:600; font-size:14px; cursor:pointer; }
.ai-chatsel:hover { background:#151a21; }
.ai-chatname { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-chatsel .chev { flex:none; color:var(--muted); transition:transform .2s ease; }
.ai-chatsel[aria-expanded="true"] .chev { transform:rotate(90deg); }
.ai-box { position:fixed; }
.ai-chats { position:absolute; left:8px; right:8px; top:52px; z-index:3; display:grid; grid-template-rows:0fr; opacity:0; pointer-events:none;
background:var(--panel); border:1px solid transparent; border-radius:10px; box-shadow:0 14px 30px rgba(0,0,0,.5);
transition:grid-template-rows .22s ease, opacity .18s ease, border-color .22s ease; }
.ai-chats.open { grid-template-rows:1fr; opacity:1; pointer-events:auto; border-color:var(--border); }
.ai-chats-in { min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.ai-chatnew { margin:6px; padding:7px 10px; text-align:left; background:none; border:1px dashed var(--border); border-radius:7px; color:var(--accent); font-size:13px; cursor:pointer; }
.ai-chatnew:hover { background:#12202f; }
.ai-chatlist { max-height:min(320px, 50vh); overflow-y:auto; padding:0 6px 6px; }
.ai-chatrow { display:flex; align-items:center; gap:4px; border-radius:7px; }
.ai-chatrow:hover, .ai-chatrow.on { background:#12202f; }
.ai-chatopen { flex:1; min-width:0; padding:7px 10px; text-align:left; background:none; border:0; color:var(--text); font-size:13px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
.ai-chatdel { flex:none; width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; background:none; border:0;
border-radius:6px; color:var(--muted); cursor:pointer; opacity:.6; }
.ai-chatrow:hover .ai-chatdel { opacity:1; }
.ai-chatdel:hover { color:var(--danger); background:rgba(255,92,92,.1); }
.ai-chatnone { padding:8px 10px; font-size:13px; }
/* An action in an answer: a compact card, as in Cursor. */
.ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; }
.ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
.ai-code.ai-action:not(:has(pre)) .ai-code-h { border-bottom:0; }
.ai-kind { display:inline-flex; align-items:center; gap:6px; min-width:0; color:var(--muted); }
.ai-kind svg { flex:none; color:var(--accent); }
.ai-kind b { font-weight:500; color:var(--text); flex:none; }
.ai-where-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace, Menlo, monospace; font-size:11.5px; }
.ai-act { margin-left:auto; flex:none; height:24px; padding:0 9px; min-height:0; line-height:24px; border-radius:6px; border:0; font-size:12px; font-weight:500;
background:var(--accent); color:#fff; cursor:pointer; }
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }