diff --git a/internal/server/ai.go b/internal/server/ai.go index d9c0755..bc28745 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -41,6 +41,8 @@ type aiConfig struct{ Type, URL, Key, Model string } type aiMsg struct { Role string `json:"role"` Content string `json:"content"` + // ThinkMS: how long the model thought before this answer (kept to show it). + ThinkMS int64 `json:"think_ms,omitempty"` } func (s *Server) aiConfig() aiConfig { @@ -440,6 +442,11 @@ func (s *Server) aiDeleteChat(owner, chat int64) { // aiAppendTo keeps a message of owner's chat; the first question names it. func (s *Server) aiAppendTo(owner, chat int64, role, content string) { + s.aiKeepMsg(owner, chat, aiMsg{Role: role, Content: content}) +} + +func (s *Server) aiKeepMsg(owner, chat int64, m aiMsg) { + role, content := m.Role, m.Content if content == "" || !s.aiOwns(owner, chat) { return } @@ -447,7 +454,7 @@ func (s *Server) aiAppendTo(owner, chat int64, role, content string) { content = utf8Safe(content[:64<<10]) } 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(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS) _, _ = 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" { @@ -461,7 +468,7 @@ func (s *Server) aiAppendTo(owner, chat int64, role, content string) { // 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) + rows, err := s.db.sql.Query(`SELECT role, content, think_ms FROM ai_messages WHERE admin_id = ? AND chat_id = ? ORDER BY id DESC LIMIT ?`, owner, chat, limit) out := []aiMsg{} if err != nil { return out @@ -469,7 +476,7 @@ func (s *Server) aiHistoryOf(owner, chat int64, limit int) []aiMsg { defer rows.Close() for rows.Next() { var m aiMsg - if rows.Scan(&m.Role, &m.Content) == nil { + if rows.Scan(&m.Role, &m.Content, &m.ThinkMS) == nil { out = append(out, m) } } @@ -541,24 +548,44 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { } if in.Think { system += aiThink + // Models heed the last words most: the reminder goes there too (not + // kept with the talk). + if n := len(msgs); n > 0 && msgs[n-1].Role == "user" { + msgs[n-1].Content += "\n\n(Сначала подумай: первая строка ответа - ::думаю::, дальше рассуждение, потом строка ::ответ:: и сам ответ.)" + } } 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 + var thinkFrom, thinkTo time.Time // when the thinking began and ended emit := func(t string) { if t == "" { return } answer.WriteString(t) + if a := answer.String(); thinkFrom.IsZero() && (strings.Contains(a, "::думаю::") || strings.Contains(a, "")) { + thinkFrom = time.Now() + } else if !thinkFrom.IsZero() && thinkTo.IsZero() && (strings.Contains(a, "::ответ::") || strings.Contains(a, "")) { + thinkTo = time.Now() + } _, _ = io.WriteString(w, t) if fl != nil { fl.Flush() } } err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit) - s.aiAppendTo(a.ID, in.Chat, "assistant", stripThink(answer.String())) + // The answer is kept whole, thinking too (to show it again); the model + // gets it without (aiWindow). + ans := aiMsg{Role: "assistant", Content: answer.String()} + if !thinkFrom.IsZero() { + if thinkTo.IsZero() { + thinkTo = time.Now() + } + ans.ThinkMS = thinkTo.Sub(thinkFrom).Milliseconds() + } + s.aiKeepMsg(a.ID, in.Chat, ans) if err != nil && r.Context().Err() == nil { if answer.Len() == 0 { w.WriteHeader(http.StatusBadGateway) @@ -679,7 +706,9 @@ func aiWindow(rows []aiMsg, budget int) []aiMsg { } m = aiMsg{Role: "user", Content: "Результат действия:\n" + cut(m.Content, max)} case m.Role == "assistant" && i < len(rows)-2: - m.Content = cut(m.Content, 3000) + m.Content = cut(stripThink(m.Content), 3000) + case m.Role == "assistant": + m.Content = stripThink(m.Content) case m.Role != "assistant": m.Role = "user" } diff --git a/internal/server/ai_test.go b/internal/server/ai_test.go index f140ed0..310d927 100644 --- a/internal/server/ai_test.go +++ b/internal/server/ai_test.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "testing" + "time" ) func collect(t *testing.T, cfg aiConfig) (string, error) { @@ -235,3 +236,33 @@ func TestAIChats(t *testing.T) { t.Fatal("another user wrote into the talk") } } + +// The thinking is kept with the answer (and how long it took), so it shows +// again later; the model never gets it back. +func TestAIThinkingKept(t *testing.T) { + s, c := newTestServer(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fl := w.(http.Flusher) + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"::думаю::\\nхм\\n\"}}]}\n\n") + fl.Flush() + time.Sleep(1100 * time.Millisecond) + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"::ответ::\\n391\"}}]}\n\ndata: [DONE]\n\n") + })) + defer srv.Close() + _ = s.db.SetConfig(cfgAIType, "openai") + _ = s.db.SetConfig(cfgAIURL, srv.URL) + a, _ := s.db.AdminByUsername("adm") + _ = s.db.SetConfig(aiUserKey(a.ID), "1") + req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"17*23","think":true}`)) + req.AddCookie(c) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + chat, _ := strconv.ParseInt(w.Header().Get("X-Chat-Id"), 10, 64) + h := s.aiHistoryOf(a.ID, chat, 10) + if len(h) != 2 || !strings.Contains(h[1].Content, "::думаю::") || h[1].ThinkMS < 1000 { + t.Fatalf("kept: %+v", h) + } + if win := aiWindow(h, 10000); strings.Contains(win[len(win)-1].Content, "хм") { + t.Fatalf("the model gets the thinking back: %+v", win) + } +} diff --git a/internal/server/db.go b/internal/server/db.go index f73e8cb..0b911b8 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -129,6 +129,7 @@ CREATE TABLE IF NOT EXISTS forward_rules ( 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`, + `ALTER TABLE ai_messages ADD COLUMN think_ms 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, diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index a8f960a..c11986d 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -207,7 +207,7 @@ 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; }); } + else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); } }); if (!h.length) q('.ai-hello').hidden = false; } catch (e) { /* stays empty */ } @@ -365,11 +365,12 @@ // Markdown; ``` blocks apart, action blocks with their button. The // thinking block is made once and only its words change, so it stays open // or shut as the user left it while the answer streams. - const CHEV = ''; + // The arrow of the + menu's groups: right shut, down open. + const CHEV = '›'; function render(el, text, done) { let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body'); if (!body) { - el.innerHTML = '