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 = `
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
// 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})
}