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 = '
' +
+ el.innerHTML = '
';
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;
diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css
index 1e7f9e9..a87e89a 100644
--- a/internal/server/web/static/app.css
+++ b/internal/server/web/static/app.css
@@ -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; }