diff --git a/daemon/autotitle.go b/daemon/autotitle.go new file mode 100644 index 0000000..21a0403 --- /dev/null +++ b/daemon/autotitle.go @@ -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 +} diff --git a/daemon/autotitle_test.go b/daemon/autotitle_test.go new file mode 100644 index 0000000..1c567d5 --- /dev/null +++ b/daemon/autotitle_test.go @@ -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") +} diff --git a/daemon/hub.go b/daemon/hub.go index da20052..fad9d1b 100644 --- a/daemon/hub.go +++ b/daemon/hub.go @@ -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