feat: the assistant's talk is kept in the database per user and the model sees a trimmed window of it; clear is a broom; the ball steps aside for the chat and comes back where the chat was

This commit is contained in:
redtea committed 2026-10-15 22:35:10 +03:00
1 parent 92a90646a5
commit 00617451aa
6 files changed
+286 -63

No files matched your search

+178 -38
View File
@@ -364,8 +364,45 @@ const aiAgent = `
const aiThink = `
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
// POST /api/panel/ai/chat - {messages, context}: the answer, streamed as
// plain text. A failure after the start ends the text with "[ошибка: …]".
// The talk is kept in the database, the last aiKeep messages of each user.
const aiKeep = 200
func (s *Server) aiAppend(owner int64, role, content string) {
if content == "" {
return
}
if len(content) > 64<<10 {
content = utf8Safe(content[:64<<10])
}
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, role, content, at) VALUES (?, ?, ?, ?)`, owner, role, content, time.Now().Unix())
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE admin_id = ? AND id <= (SELECT id FROM ai_messages WHERE admin_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, owner, owner, aiKeep)
}
// aiHistory is the user's talk, oldest first, the last limit messages.
func (s *Server) aiHistory(owner int64, limit int) []aiMsg {
rows, err := s.db.sql.Query(`SELECT role, content FROM ai_messages WHERE admin_id = ? ORDER BY id DESC LIMIT ?`, owner, limit)
out := []aiMsg{}
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var m aiMsg
if rows.Scan(&m.Role, &m.Content) == nil {
out = append(out, m)
}
}
for l, r := 0, len(out)-1; l < r; l, r = l+1, r-1 {
out[l], out[r] = out[r], out[l]
}
return out
}
// POST /api/panel/ai/chat - {message | result, context, agent, think}: the
// new words (or an action's result) are kept with the talk, and the answer,
// streamed as plain text, is kept too. The model sees a window of the talk
// (aiWindow) and the panel as it is now. A failure after the start ends the
// text with "[ошибка: …]".
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
@@ -373,44 +410,21 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
return
}
var in struct {
Messages []aiMsg `json:"messages"`
Context string `json:"context"`
Agent bool `json:"agent"`
Think bool `json:"think"`
Message string `json:"message"`
Result string `json:"result"`
Context string `json:"context"`
Agent bool `json:"agent"`
Think bool `json:"think"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || len(in.Messages) == 0 {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "") {
http.Error(w, "плохой запрос", http.StatusBadRequest)
return
}
// The last 30 messages are enough to go on.
if n := len(in.Messages); n > 30 {
in.Messages = in.Messages[n-30:]
}
for i := range in.Messages {
if in.Messages[i].Role != "assistant" {
in.Messages[i].Role = "user"
}
}
if len(in.Context) > 60000 {
in.Context = in.Context[len(in.Context)-60000:]
for !utf8.ValidString(in.Context) && in.Context != "" {
in.Context = in.Context[1:]
}
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
fl, _ := w.(http.Flusher)
wrote := false
emit := func(t string) {
if t == "" {
return
}
wrote = true
_, _ = io.WriteString(w, t)
if fl != nil {
fl.Flush()
}
s.aiAppend(a.ID, "result", in.Result)
s.aiAppend(a.ID, "user", in.Message)
msgs := aiWindow(s.aiHistory(a.ID, 60), 24000)
if len(in.Context) > 30000 {
in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
}
system := aiSystem
if in.Agent {
@@ -419,15 +433,54 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
if in.Think {
system += aiThink
}
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
fl, _ := w.(http.Flusher)
var answer strings.Builder
emit := func(t string) {
if t == "" {
return
}
answer.WriteString(t)
_, _ = io.WriteString(w, t)
if fl != nil {
fl.Flush()
}
}
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
s.aiAppend(a.ID, "assistant", stripThink(answer.String()))
if err != nil && r.Context().Err() == nil {
if !wrote {
if answer.Len() == 0 {
w.WriteHeader(http.StatusBadGateway)
}
_, _ = io.WriteString(w, "\n[ошибка: "+err.Error()+"]")
}
}
// GET /api/panel/ai/history - the talk to show, oldest first.
func (s *Server) handleAIHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.aiHistory(s.currentAdmin(r).ID, 60))
}
// POST /api/panel/ai/clear - a new talk.
func (s *Server) handleAIClear(w http.ResponseWriter, r *http.Request) {
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE admin_id = ?`, s.currentAdmin(r).ID)
writeJSON(w, map[string]any{"ok": true})
}
// POST /api/panel/ai/note - {content}: an action's result the user got by
// hand, kept with the talk for the next question.
func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) {
var in struct {
Content string `json:"content"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in) == nil {
s.aiAppend(s.currentAdmin(r).ID, "result", in.Content)
}
writeJSON(w, map[string]any{"ok": true})
}
// GET /api/panel/ai - whether the assistant is on for this user.
func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
@@ -473,3 +526,90 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
}
// aiWindow is what of the stored talk goes to the model: the newest
// messages within budget characters - the last one always - with older
// action results and answers cut to their head and tail (the newest result
// whole), results given as the user's words, and messages of one role in a
// row merged (some models want them alternating). It starts with the user.
func aiWindow(rows []aiMsg, budget int) []aiMsg {
cut := func(s string, max int) string {
if len(s) <= max {
return s
}
head, tail := max/3, max-max/3
return utf8Safe(s[:head]) + "\n…[сокращено]…\n" + utf8Safe(s[len(s)-tail:])
}
lastResult := -1
for i := len(rows) - 1; i >= 0; i-- {
if rows[i].Role == "result" {
lastResult = i
break
}
}
var picked []aiMsg
total := 0
for i := len(rows) - 1; i >= 0; i-- {
m := rows[i]
switch {
case m.Role == "result":
max := 1500
if i == lastResult {
max = 12000
}
m = aiMsg{Role: "user", Content: "Результат действия:\n" + cut(m.Content, max)}
case m.Role == "assistant" && i < len(rows)-2:
m.Content = cut(m.Content, 3000)
case m.Role != "assistant":
m.Role = "user"
}
if len(picked) > 0 && total+len(m.Content) > budget {
break
}
total += len(m.Content)
picked = append(picked, m)
}
// Oldest first, from the user's first word.
for l, r := 0, len(picked)-1; l < r; l, r = l+1, r-1 {
picked[l], picked[r] = picked[r], picked[l]
}
for len(picked) > 0 && picked[0].Role != "user" {
picked = picked[1:]
}
var out []aiMsg
for _, m := range picked {
if n := len(out); n > 0 && out[n-1].Role == m.Role {
out[n-1].Content += "\n\n" + m.Content
continue
}
out = append(out, m)
}
return out
}
// utf8Safe trims broken runes off the ends of a cut string.
func utf8Safe(s string) string {
for len(s) > 0 && !utf8.ValidString(s) {
if r, _ := utf8.DecodeRuneInString(s); r == utf8.RuneError {
s = s[1:]
} else {
s = s[:len(s)-1]
}
}
return s
}
// stripThink drops the thinking (::думаю:: … ::ответ::, or <think>) before
// an answer is kept: it is not worth its room in the talk.
func stripThink(s string) string {
t := strings.TrimLeft(s, " \n")
for _, p := range [][2]string{{"::думаю::", "::ответ::"}, {"<think>", "</think>"}} {
if strings.HasPrefix(t, p[0]) {
if i := strings.Index(t, p[1]); i >= 0 {
return strings.TrimLeft(t[i+len(p[1]):], " \n")
}
return ""
}
}
return s
}