From 80ed7d4ffa645512bcdabb9b64618c34d5bf8ae4 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 11:23:52 +0300 Subject: [PATCH] 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 --- internal/server/ai.go | 45 ++++++++++++++++++++++++++++++ internal/server/ai_test.go | 37 +++++++++++++++++++++++- internal/server/web/static/ai.js | 6 +++- internal/server/web/static/app.css | 5 +++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/internal/server/ai.go b/internal/server/ai.go index 8da08c8..4ddd253 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "strconv" "strings" @@ -537,8 +538,13 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { in.Chat = s.aiNewChat(a.ID) } 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, "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) if 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. func (s *Server) handleAIChats(w http.ResponseWriter, r *http.Request) { a := s.currentAdmin(r) diff --git a/internal/server/ai_test.go b/internal/server/ai_test.go index 310d927..7cf508c 100644 --- a/internal/server/ai_test.go +++ b/internal/server/ai_test.go @@ -150,7 +150,8 @@ func TestAIChatNeedsUserOn(t *testing.T) { } // The talk is kept: the question and the answer. 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")) } 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) } } + +// 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)) +} diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index c610833..051418c 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -483,7 +483,11 @@ }); // A new chat gets its id (and its name) from the answer. 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; } const contentType = r.headers.get('content-type') || ''; if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text())); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index a93d4b9..c7d7baf 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -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-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 … */ -.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-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; }