162 lines
4.3 KiB
Go
162 lines
4.3 KiB
Go
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": 512,
|
|
"thinking": map[string]any{"type": "disabled"},
|
|
})
|
|
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
|
|
}
|