fix: Думать - the model is reminded in the question itself, the label no longer lands in the arrow, and the thinking with its time is kept to show again later

This commit is contained in:
redtea committed 2026-10-16 10:29:44 +03:00
1 parent 3b7a6271ac
commit fa62f2c5b6
5 files changed
+78 -15

No files matched your search

+34 -5
View File
@@ -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, "<think>")) {
thinkFrom = time.Now()
} else if !thinkFrom.IsZero() && thinkTo.IsZero() && (strings.Contains(a, "::ответ::") || strings.Contains(a, "</think>")) {
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"
}
+31
View File
@@ -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)
}
}
+1
View File
@@ -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,
+9 -7
View File
@@ -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 = '<svg 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>';
// The arrow of the + menu's groups: right shut, down open.
const CHEV = '<span class="chev">›</span>';
function render(el, text, done) {
let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body');
if (!body) {
el.innerHTML = '<div class="ai-think" hidden><button type="button" class="ai-think-h">' + CHEV + '<span></span></button>' +
el.innerHTML = '<div class="ai-think" hidden><button type="button" class="ai-think-h">' + CHEV + '<span class="lbl"></span></button>' +
'<div class="ai-think-b"><div class="ai-think-in"></div></div></div><div class="ai-body"></div>';
think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body');
think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open');
@@ -381,8 +382,9 @@
if (!going && !el._t1) el._t1 = Date.now();
think.hidden = false;
think.classList.toggle('going', going);
think.querySelector('.ai-think-h span').textContent = going ? 'Думаю…'
: 'Думал ' + Math.max(1, Math.round((el._t1 - el._t0) / 1000)) + ' с';
// How long: timed here as it streams, or kept with the answer.
const ms = el._thinkMs || (el._t1 - el._t0);
think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' с';
think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim());
text = text.slice(th[0].length);
}
@@ -407,11 +409,11 @@
body.innerHTML = html;
el.dataset.text = text;
}
function add(role, text) {
function add(role, text, thinkMs) {
q('.ai-hello').hidden = true;
const el = document.createElement('div');
el.className = 'ai-msg ' + role;
if (role === 'assistant') render(el, text, true); else el.textContent = text;
if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); } else el.textContent = text;
list.appendChild(el);
list.scrollTop = list.scrollHeight;
return el;
+3 -3
View File
@@ -943,9 +943,9 @@ main#app > .view:not([hidden]) { animation:view-in .22s ease; } /* no fill: a la
.ai-think[hidden] { display:none; }
.ai-think-h { display:inline-flex; align-items:center; gap:5px; padding:2px 4px 2px 0; background:none; border:0; color:var(--muted); font-size:12.5px; cursor:pointer; }
.ai-think-h:hover { color:var(--text); }
.ai-think-h svg { transition:transform .2s ease; }
.ai-think.open .ai-think-h svg { transform:rotate(90deg); }
.ai-think.going .ai-think-h span { background:linear-gradient(90deg, var(--muted) 30%, var(--text) 50%, var(--muted) 70%) 0 0/200% 100%;
.ai-think-h .chev { display:inline-block; width:10px; text-align:center; font-size:15px; line-height:1; transition:transform .2s ease; }
.ai-think.open .ai-think-h .chev { transform:rotate(90deg); }
.ai-think.going .ai-think-h .lbl { background:linear-gradient(90deg, var(--muted) 30%, var(--text) 50%, var(--muted) 70%) 0 0/200% 100%;
-webkit-background-clip:text; background-clip:text; color:transparent; animation:ai-shine 1.4s linear infinite; }
@keyframes ai-shine { to { background-position:-200% 0; } }
.ai-think-b { display:grid; grid-template-rows:0fr; transition:grid-template-rows .22s ease, opacity .2s ease; opacity:0; }