feat: LLM auto-titles — daemon names unnamed sessions from their first user message via one cheap ZAI completion (no agent-turn pollution); flows into sidebar + tab title
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
// autotitle.go — LLM-generated session titles. When the first user message
|
||||
// of an unnamed session persists, the daemon makes one cheap chat-completion
|
||||
// call (the default provider, ZAI-compatible endpoint) asking for a 3-6 word
|
||||
// title, then stores it via the normal rename path so session_list, the web
|
||||
// sidebar and the tab title pick it up.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
autoTitleMaxChars int = 400 // prompt excerpt cap
|
||||
autoTitleTimeout time.Duration = 20 * time.Second
|
||||
autoTitlePromptTail string = "\n\nReply with ONLY a 3-6 word title for this request. No quotes, no punctuation at the end, no explanation."
|
||||
)
|
||||
|
||||
var autoTitleTried = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]bool
|
||||
}{m: map[string]bool{}}
|
||||
|
||||
// needsAutoTitle reports whether frame f is a persisted user message_end of
|
||||
// a session that has no name yet and no prior attempt.
|
||||
func (h *Hub) needsAutoTitle(f frame) bool {
|
||||
if f.typ != evMessageEnd {
|
||||
return false
|
||||
}
|
||||
var probe struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(f.raw, &probe); err != nil || probe.Message.Role != "user" {
|
||||
return false
|
||||
}
|
||||
autoTitleTried.mu.Lock()
|
||||
defer autoTitleTried.mu.Unlock()
|
||||
if autoTitleTried.m[f.sessionID] {
|
||||
return false
|
||||
}
|
||||
if rows, err := h.store.Sessions(); err == nil {
|
||||
for _, row := range rows {
|
||||
if row.Info.ID == f.sessionID {
|
||||
if row.Info.Name != nil && *row.Info.Name != "" {
|
||||
autoTitleTried.m[f.sessionID] = true
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
autoTitleTried.m[f.sessionID] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// firstUserText extracts the message text from a message_end frame payload.
|
||||
func firstUserText(raw []byte) string {
|
||||
var probe struct {
|
||||
Message struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return ""
|
||||
}
|
||||
return probe.Message.Text
|
||||
}
|
||||
|
||||
// autoTitle asks the provider API for a title and applies it on success.
|
||||
func (h *Hub) autoTitle(sessionID, userText string) {
|
||||
title, err := requestTitle(userText)
|
||||
if err != nil {
|
||||
log.Printf("autotitle %s: %v", sessionID, err)
|
||||
return
|
||||
}
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
if _, err := h.store.SetSessionName(sessionID, title); err != nil {
|
||||
log.Printf("autotitle %s: set name: %v", sessionID, err)
|
||||
return
|
||||
}
|
||||
// live conns see it via session_list; an open chat also refreshes on it.
|
||||
h.BroadcastSessionList()
|
||||
}
|
||||
|
||||
// requestTitle performs the one-shot completion against the ZAI-compatible
|
||||
// endpoint configured for the default provider.
|
||||
func requestTitle(userText string) (string, error) {
|
||||
key := os.Getenv(envProviderAPIKey)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("no %s configured", envProviderAPIKey)
|
||||
}
|
||||
if len(userText) > autoTitleMaxChars {
|
||||
userText = userText[:autoTitleMaxChars]
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": envOr("LVMH_AUTOTITLE_MODEL", "glm-5.3"),
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": userText + autoTitlePromptTail},
|
||||
},
|
||||
"max_tokens": 24,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), autoTitleTimeout)
|
||||
defer cancel()
|
||||
endpoint := envOr("LVMH_AUTOTITLE_URL", "https://api.z.ai/api/coding/paas/v4/chat/completions")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("title api: %s", resp.Status)
|
||||
}
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("empty choices")
|
||||
}
|
||||
title := strings.TrimSpace(out.Choices[0].Message.Content)
|
||||
title = strings.Trim(title, "\"'`*. ")
|
||||
if idx := strings.IndexAny(title, "\n"); idx >= 0 {
|
||||
title = strings.TrimSpace(title[:idx])
|
||||
}
|
||||
if len(title) > 60 {
|
||||
title = title[:60]
|
||||
}
|
||||
if title == "" {
|
||||
return "", fmt.Errorf("empty title")
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
// autotitle_test.go — needsAutoTitle gating, firstUserText extraction,
|
||||
// requestTitle against a fake ZAI endpoint, apply path renames + broadcasts.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func userMsgEndFrame(sid string, seq int64, text string) []byte {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"v": 1, "sessionId": sid, "seq": seq, "ts": 1, "type": "message_end",
|
||||
"message": map[string]any{"role": "user", "id": "u", "text": text,
|
||||
"thinking": nil, "toolCalls": []any{}, "toolCallId": nil},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
func TestNeedsAutoTitleGating(t *testing.T) {
|
||||
_, _, hub := newTestServerHub(t)
|
||||
f := frame{typ: evMessageEnd, sessionID: "s-a", raw: userMsgEndFrame("s-a", 1, "hello")}
|
||||
if !hub.needsAutoTitle(f) {
|
||||
t.Fatal("first unnamed user message must want a title")
|
||||
}
|
||||
if hub.needsAutoTitle(f) {
|
||||
t.Fatal("second attempt for same session must be gated")
|
||||
}
|
||||
// assistant frames never trigger
|
||||
fa := frame{typ: evMessageEnd, sessionID: "s-b", raw: []byte(`{"message":{"role":"assistant"}}`)}
|
||||
if hub.needsAutoTitle(fa) {
|
||||
t.Fatal("assistant message must not trigger")
|
||||
}
|
||||
// named session never triggers (persisted via UpsertSession)
|
||||
if err := hub.store.UpsertSession(SessionInfo{ID: "s-c", Name: strPtr("named")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fc := frame{typ: evMessageEnd, sessionID: "s-c", raw: userMsgEndFrame("s-c", 1, "hi")}
|
||||
if hub.needsAutoTitle(fc) {
|
||||
t.Fatal("named session must not trigger")
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func dummyTitleServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Auto generated title"}}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func TestFirstUserText(t *testing.T) {
|
||||
if got := firstUserText(userMsgEndFrame("s", 1, "build me a thing")); got != "build me a thing" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := firstUserText([]byte("garbage")); got != "" {
|
||||
t.Fatalf("garbage -> %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestTitle(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.Messages) == 0 || req.Messages[0].Content == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]string{"content": " \"Fix login timeout bug\".\n"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||
got, err := requestTitle("the login page times out after 5s in production")
|
||||
if err != nil {
|
||||
t.Fatalf("requestTitle: %v", err)
|
||||
}
|
||||
if got != "Fix login timeout bug" {
|
||||
t.Fatalf("title = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleAppliesRename(t *testing.T) {
|
||||
_, _, hub := newTestServerHub(t)
|
||||
t.Setenv("LVMH_AUTOTITLE_URL", dummyTitleServer(t))
|
||||
if err := hub.store.UpsertSession(SessionInfo{ID: "s-t"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub.autoTitle("s-t", "anything")
|
||||
rows, err := hub.store.Sessions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Info.ID == "s-t" {
|
||||
if row.Info.Name == nil || *row.Info.Name == "" {
|
||||
t.Fatal("name not applied")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("session row missing")
|
||||
}
|
||||
@@ -614,6 +614,16 @@ func (h *Hub) handleEvent(f frame) {
|
||||
log.Printf("hub: touch session %s: %v", f.sessionID, err)
|
||||
}
|
||||
}
|
||||
// Auto-title: first persisted user message on an unnamed session asks
|
||||
// the configured LLM for a short title (separate cheap completion; never
|
||||
// touches the agent conversation).
|
||||
if f.typ == evMessageEnd && h.needsAutoTitle(f) {
|
||||
sid := f.sessionID
|
||||
text := firstUserText(f.raw)
|
||||
if text != "" {
|
||||
go h.autoTitle(sid, text)
|
||||
}
|
||||
}
|
||||
// Track mid-turn state for the session-list activity pulse.
|
||||
if f.typ == evAgentStart || f.typ == evAgentSettled {
|
||||
busy := f.typ == evAgentStart
|
||||
|
||||
Reference in New Issue
Block a user