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:
1 parent
6d8f39f2a5
commit
b23943ff42
8 files changed
+385
-51
No files matched your search
+146
-22
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ai-box';
|
||||
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 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>' +
|
||||
'<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>' +
|
||||
'<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>' +
|
||||
'<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-pills">' +
|
||||
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
|
||||
@@ -141,24 +142,82 @@
|
||||
});
|
||||
}
|
||||
q('.ai-close').onclick = hide;
|
||||
q('.ai-clear').onclick = async () => {
|
||||
if (!list.querySelector('.ai-msg') || !await UI.confirm('Очистить разговор?', {ok: 'Очистить', danger: true})) return;
|
||||
if (ac) ac.abort();
|
||||
fetch('/api/panel/ai/clear', {method: 'POST', credentials: 'same-origin'});
|
||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||
q('.ai-hello').hidden = false;
|
||||
// ---------- chats ----------
|
||||
// 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();
|
||||
};
|
||||
// The talk so far, once, when the chat first opens.
|
||||
async function loadTalk() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
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();
|
||||
setChat(id);
|
||||
drawChats();
|
||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||
q('.ai-hello').hidden = !!id;
|
||||
if (!id) return;
|
||||
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 => {
|
||||
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; }
|
||||
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 ----------
|
||||
@@ -296,7 +355,11 @@
|
||||
// ---------- messages ----------
|
||||
// stripThink: the answer without its thinking - what is acted on and kept.
|
||||
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 draws an answer: its thinking, folded, above; the text with
|
||||
// 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 closed = i < parts.length - 1 || done;
|
||||
const act = LABEL[kind] && closed;
|
||||
html += '<div class="ai-code">' + (act && head !== kind ? '<div class="muted ai-cap">' + esc(head) + '</div>' : '') +
|
||||
(code || !act ? '<pre>' + esc(code) + '</pre>' : '') +
|
||||
(act ? '<button type="button" class="btn small ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button>' : '') + '</div>';
|
||||
// An action: a card with what and where on its title line, a small
|
||||
// button on the right of it, the command or text below.
|
||||
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;
|
||||
el.dataset.text = text;
|
||||
@@ -364,7 +433,7 @@
|
||||
const res = await perform(a);
|
||||
b.textContent = /^Ошибка/.test(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) {
|
||||
@@ -388,8 +457,11 @@
|
||||
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({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; }
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text()));
|
||||
|
||||
@@ -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.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; }
|
||||
/* 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; }
|
||||
Reference in new issue
Block a user