feat: a new chat is named by the model in two or three words, asked aside; the machine's name gets its room in the chat's header

This commit is contained in:
redtea committed 2026-10-16 11:23:52 +03:00
1 parent 6442c39360
commit 80ed7d4ffa
4 files changed
+90 -3

No files matched your search

+45
View File
@@ -10,6 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -537,8 +538,13 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
in.Chat = s.aiNewChat(a.ID) in.Chat = s.aiNewChat(a.ID)
} }
w.Header().Set("X-Chat-Id", strconv.FormatInt(in.Chat, 10)) w.Header().Set("X-Chat-Id", strconv.FormatInt(in.Chat, 10))
fresh := in.Message != "" && len(s.aiHistoryOf(a.ID, in.Chat, 1)) == 0
s.aiAppendTo(a.ID, in.Chat, "result", in.Result) s.aiAppendTo(a.ID, in.Chat, "result", in.Result)
s.aiAppendTo(a.ID, in.Chat, "user", in.Message) s.aiAppendTo(a.ID, in.Chat, "user", in.Message)
if fresh {
// A new chat gets a short name from the model, aside.
go s.aiNameChat(a.ID, in.Chat, in.Message)
}
msgs := aiWindow(s.aiHistoryOf(a.ID, in.Chat, 60), 24000) msgs := aiWindow(s.aiHistoryOf(a.ID, in.Chat, 60), 24000)
if len(in.Context) > 30000 { if len(in.Context) > 30000 {
in.Context = utf8Safe(in.Context[len(in.Context)-30000:]) in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
@@ -598,6 +604,45 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
} }
} }
// aiNameChat asks the model for a two-or-three-word name of the chat its
// first question opened, and gives the chat that name.
func (s *Server) aiNameChat(owner, chat int64, question string) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if r := []rune(question); len(r) > 600 {
question = string(r[:600])
}
var b strings.Builder
err := aiStream(ctx, s.aiConfig(), "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
if err != nil {
log.Printf("ai: naming chat %d: %v", chat, err)
return
}
if t := chatTitle(b.String()); t != "" {
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET title = ? WHERE id = ? AND admin_id = ?`, t, chat, owner)
}
}
// chatTitle tidies what the model gave for a name: the first line, no
// thinking, quotes or end mark, four words at most.
func chatTitle(s string) string {
s = stripThink(s)
if i := strings.IndexByte(strings.TrimSpace(s), '\n'); i >= 0 {
s = strings.TrimSpace(s)[:i]
}
s = strings.Trim(strings.TrimSpace(s), "\"'«»„“”`*#.!:;")
w := strings.Fields(s)
if len(w) > 4 {
w = w[:4]
}
s = strings.Join(w, " ")
if r := []rune(s); len(r) > 40 {
s = string(r[:40])
}
return s
}
// GET /api/panel/ai/chats - the user's chats, newest first. // GET /api/panel/ai/chats - the user's chats, newest first.
func (s *Server) handleAIChats(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAIChats(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r) a := s.currentAdmin(r)
+36 -1
View File
@@ -150,7 +150,8 @@ func TestAIChatNeedsUserOn(t *testing.T) {
} }
// The talk is kept: the question and the answer. // The talk is kept: the question and the answer.
cs := s.aiChats(a.ID) cs := s.aiChats(a.ID)
if len(cs) != 1 || cs[0].Title != "hi" || w.Header().Get("X-Chat-Id") != strconv.FormatInt(cs[0].ID, 10) { // Named by the question at once (the model may rename it a moment later).
if len(cs) != 1 || cs[0].Title == "" || w.Header().Get("X-Chat-Id") != strconv.FormatInt(cs[0].ID, 10) {
t.Fatalf("chats: %+v %q", cs, w.Header().Get("X-Chat-Id")) t.Fatalf("chats: %+v %q", cs, w.Header().Get("X-Chat-Id"))
} }
if h := s.aiHistoryOf(a.ID, cs[0].ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" { if h := s.aiHistoryOf(a.ID, cs[0].ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" {
@@ -266,3 +267,37 @@ func TestAIThinkingKept(t *testing.T) {
t.Fatalf("the model gets the thinking back: %+v", win) t.Fatalf("the model gets the thinking back: %+v", win)
} }
} }
// A new chat is named by the model: its first question in two or three
// words, asked aside so the answer does not wait.
func TestAIChatTitle(t *testing.T) {
s, c := newTestServer(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in struct {
Messages []aiMsg `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
reply := "ok"
if strings.Contains(in.Messages[0].Content, "название") {
reply = "«Выбор поисковика.»"
}
b, _ := json.Marshal(reply)
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\ndata: [DONE]\n\n", b)
}))
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":"какой поисковик ты используешь сейчас"}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
for i := 0; i < 50; i++ {
if cs := s.aiChats(a.ID); len(cs) == 1 && cs[0].Title == "Выбор поисковика" {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("title: %+v", s.aiChats(a.ID))
}
+5 -1
View File
@@ -483,7 +483,11 @@
}); });
// A new chat gets its id (and its name) from the answer. // A new chat gets its id (and its name) from the answer.
const cid = Number(r.headers.get('X-Chat-Id')) || 0; const cid = Number(r.headers.get('X-Chat-Id')) || 0;
if (cid && cid !== chatId) { setChat(cid); loadChats(); } if (cid && cid !== chatId) {
setChat(cid); loadChats();
// The model names the new chat a moment later.
setTimeout(loadChats, 4000); setTimeout(loadChats, 12000);
}
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; } if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
const contentType = r.headers.get('content-type') || ''; const contentType = r.headers.get('content-type') || '';
if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text())); if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text()));
+4 -1
View File
@@ -852,7 +852,10 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-fab:hover { transform:scale(1.06); box-shadow:0 10px 28px rgba(91,140,255,.45); } .ai-fab:hover { transform:scale(1.06); box-shadow:0 10px 28px rgba(91,140,255,.45); }
.ai-head { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--border); } .ai-head { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--border); }
/* The machine the chat sees: its name on one line, cut with … */ /* The machine the chat sees: its name on one line, cut with … */
.ai-head .ai-where { flex:0 1 auto; min-width:0; max-width:30%; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } /* The machine's name: room for 7-8 letters at least, the chat's name gives way. */
.ai-head .ai-where { flex:0 0 auto; min-width:0; max-width:40%; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ai-head .ai-chatsel { min-width:0; }
.ai-head .ai-chatname { min-width:3ch; }
.ai-head .ai-chatsel { flex:0 1 auto; } .ai-head .ai-chatsel { flex:0 1 auto; }
.ai-list { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; } .ai-list { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
.ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; } .ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; }