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:
1 parent
92a90646a5
commit
00617451aa
6 files changed
+286
-63
No files matched your search
+178
-38
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user