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 }